How LCP works
Which elements count, how candidates change during load, and when the browser stops looking. Thirteen small pages loaded in Chrome, Firefox and WebKit: text then a photo, shrunk, stretched and half-hidden photos, a backdrop filling the window, a photo faded in, one slid into view, a removed splash, a web font, and a video poster.
- 01Predict the LCP elementSay which elements can count, how big the browser counts each one, and which ones it leaves out.
- 02Read the candidatesExplain why a page has several LCP entries, why the last one is the LCP, and what makes them stop.
- 03Compare browsers fairlySpot where Chrome, Firefox and Safari pick a different element or time, and compare each browser with itself.
The largest thing, and when it painted
Asha opens the tea shop's spring sale. Almost at once, a line of words appears: "Everything in the shop is 20% off until Sunday." Then she waits. The big photo of tea, the thing she came to see, shows up more than half a second later.
So when did the page load, for Asha? When the words appeared, or when the photo did? She'd say the photo. That's what she came for. Largest Contentful Paint, or LCP, tries to answer the way she would. It is the time the largest piece of content on the screen was painted: the largest image, video or block of text.
Here are both moments from Asha's visit, in Chrome:
// Asha's visit in Chrome. Times in ms, the median of 5 runs.const firstContentfulPaint = 28; // the line of wordsconst largestContentfulPaint = 612; // the big tea photo console.log(`The first words appeared at ${firstContentfulPaint} ms.`);console.log(`The tea photo appeared at ${largestContentfulPaint} ms.`);console.log(`The photo came ${largestContentfulPaint - firstContentfulPaint} ms after the words.`);- Line 1 is a comment. Each time is in milliseconds (ms), counted from the start of the visit. Each is the median of 5 runs: the middle value once they're sorted.
- Line 2 is when the first words were painted. That moment is called First Contentful Paint, or FCP: the first text or image on the screen.
- Line 3 is when the tea photo was painted. That's Asha's LCP.
- Lines 5 to 7 print
The first words appeared at 28 ms.,The tea photo appeared at 612 ms.andThe photo came 584 ms after the words.
The words told Asha the page was coming. The photo showed her what she came for. If you only measured the first paint, you'd never see those 584 ms.
At 28 ms, though, the photo hadn't painted yet. So the browser noted the line of words as the largest thing so far. When the photo painted, it swapped the photo in. Here is the whole visit on one line:
At a café, a basket of bread lands on your table a minute after you sit down. Your curry comes ten minutes later. When a friend asks how long the food took, you say ten minutes. The bread was nice, but you came for the curry.
- In real life: The bread basket, first on the table
- In a page load: The first text or image painted: FCP, at 28 ms
- In real life: The curry you came for
- In a page load: The largest thing painted: the tea photo
- In real life: "The food took ten minutes"
- In a page load: LCP: 612 ms, counted from the start of the visit
The browser can't ask Asha what she came for. So it makes a simple guess: the biggest thing on the screen is probably the main course. The LCP spec says the largest paint "is likely to signify a meaningful event from the user's perspective".
LCP uses the same clock as The navigation timeline. It starts when the visit starts. So it also holds everything before the first byte, like redirects and setting up the connection. Core Web Vitals set its target: 2.5 seconds or less.
LCP is the time the largest image, video or block of text inside the window was painted. While the page loads, the browser keeps a "biggest so far" and replaces it whenever something bigger paints. It stops at the visitor's first tap, click, key press or scroll. The last one before then is the LCP.
How the course tested the rules
On a real shop page, too much happens at once to see each rule. So the course built thirteen tiny pages, each testing one rule:
- Every page has the sale's line of words, plus one thing to test, like a stretched photo or a backdrop.
- The server held some files back for a fixed time, so things painted in a known order. The tea photo waited 600 ms, for example.
- Every page was loaded 5 times in Chrome, Firefox and WebKit, the engine inside Safari. The window was 800 by 600 px.
- Nobody clicked, typed or scrolled, so nothing stopped the browser looking early.
- Three seconds in, the course read every LCP entry the page had.
Pick a page and a browser to see every "biggest so far" it recorded. Text, then a photo in Chrome is Asha's visit:
- FCP28 ms
- Candidate 1: intro7,812 px² at 32 ms
- Candidate 2: hero273,280 px² at 612 ms
- LCP, the last candidatehero at 612 ms
Each candidate row is one "biggest so far" entry, with its size and time. The last one is the LCP. FCP and LCP are the medians of 5 runs; the candidate rows come from the run whose LCP was the median. Nobody clicked, typed or scrolled, so nothing stopped the browser looking early.
Elements that can be LCP
Not everything on a page can be the LCP. The browser only looks at the kinds of thing a person would call content: pictures, video and text. web.dev lists five kinds:
- An
<img>. For an animated GIF or PNG, the time its first frame was shown counts. - An
<image>inside an<svg>. - A
<video>. Its time is when its poster image loaded or its first frame was shown, whichever came first. - An element with a background image loaded through
url()in CSS. A CSS gradient doesn't count. - A block of text, like a
<p>or an<h1>.
An <svg> drawing written straight into the HTML isn't on the list. web.dev says <svg> elements "are not considered LCP candidates at this time". An <img> that shows an SVG file is fine, though.
A block of text counts as one piece
For text, the browser measures whole blocks, not single words. Here's the sale line with two words in bold:
<!-- One block of text, with two bold words inside it -->
<p id="intro">Everything in the shop is <strong>20% off</strong> until Sunday.</p>- Line 1 is a comment.
- Line 2 is one paragraph. The words
20% offsit inside a<strong>in the middle of it.
The bold words don't get an entry of their own. A block-level element is one that starts on its own line, like a paragraph. web.dev says each piece of text "belongs to (and only to) its closest block-level ancestor element". Here, that's the <p>. Its size is the smallest rectangle around all of its words. Margins, padding and borders don't count.
A video counts by its poster
The course tested the video rule with a 640 by 360 px video. Its poster, the still picture shown before a video plays, was held back 400 ms. The video file itself was held back 1,200 ms.
In Chrome, the video became the LCP at 416 ms. Its entry's url was /poster.jpg. The poster arrived first, so the poster set the time. Try A video with a poster in the playground.
Why it matters: if your LCP is a video, a fast poster can set its time.
Which browsers report LCP at all? Chrome has since version 77. Firefox and Safari added it later:
Candidates and the final value
Asha's visit had two calls of "biggest so far": the words, then the photo. Each call is an entry of type largest-contentful-paint, often called an LCP candidate. In Paint, LCP, and layout shift APIs, these calls came from a commentator calling out the biggest runner so far. This lesson is about how things get measured, so picture a judge with a tape measure:
At a village show, a judge measures each pumpkin as it's wheeled in. When one beats the biggest so far, she writes its name on the board. A smaller one never goes up. When the doors close, the last name on the board wins.
- In real life: A pumpkin wheeled in
- In the browser: An image or block of text painted for the first time
- In real life: A new name on the board
- In the browser: A new
largest-contentful-paintentry: a candidate - In real life: A smaller pumpkin, never written up
- In the browser: A smaller element: no entry
- In real life: The doors closing
- In the browser: The visitor's first tap, click, key press or scroll
- In real life: The last name on the board
- In the browser: The LCP
web.dev says the browser writes a first entry "as soon as the browser has painted the first frame". Then it writes another "any time the largest contentful element changes." Here are Chrome's entries for Asha's visit:
// Chrome's largest-contentful-paint entries for Asha's visit: the same in every run, with median times in ms.const entries = [ { element: "intro", size: 7812, time: 28 }, { element: "hero", size: 273280, time: 612 },]; for (const { element, size, time } of entries) console.log(`At ${time} ms, the largest so far was ${element}: ${size} px².`);const last = entries.at(-1);console.log(`LCP is the last one: ${last.element}, at ${last.time} ms.`);- Lines 2 to 5 hold Chrome's two entries. They were the same in every run, and the times are medians.
- Line 7 prints one sentence per entry:
At 28 ms, the largest so far was intro: 7812 px².andAt 612 ms, the largest so far was hero: 273280 px². - Line 8 takes the last entry. Line 9 prints
LCP is the last one: hero, at 612 ms.
Each entry names its element by its id. intro is the line of words, and hero is the tea photo. The size is the area the element covers on the screen, in square pixels (px²). The tea photo is 640 by 427 px, so it covers 273,280 px².
Two things on the page never made the board. Below the tea photo sits a small one, 160 by 107 px, held back 1,200 ms. It came after the tea photo and was far smaller, so it got no entry. The heading, Spring sale, got none either. Its letters are bigger, but it's a short line. Firefox, which sometimes called out the heading before the line, measured the heading at 6,542 px² and the line at 8,680.
Why it matters: before you speed up an element, check that it really is the LCP. The last entry names it.
When the doors close
The judging stops at the visitor's first input. web.dev: "The browser will stop reporting new entries as soon as the user interacts with the page (via a tap, scroll, or keypress)". After that, whatever appears is probably there because of the visitor, not because the page was still loading. Paint, LCP, and layout shift APIs measured it: a click before the big photo arrived left LCP on a small photo.
The metric adds a few rules on top of the entries. The web-vitals library showed them:
- The library reports LCP once the visitor clicks or presses a key, or the page is hidden.
- Google's tools leave out LCP for a page that loaded in a background tab.
- A prerendered page counts from the moment the visitor opened it,
activationStart, not from when it started loading.
Nobody touched the course's pages, so every entry they wrote was kept.
Only the part inside the window counts
How big is the tea photo? The browser doesn't measure the whole element. It measures the part you can see.
A gallery hangs a big canvas in a smaller frame. The rest of the canvas is rolled up behind it, out of sight. In the catalogue, the gallery lists the size you can see inside the frame, not the whole roll of canvas.
- In real life: The frame
- In the browser: The window, which the spec calls the viewport
- In real life: The canvas rolled up behind the frame
- In the browser: The part of an element outside the window, or cut off
- In real life: The size in the catalogue
- In the browser: The entry's
size: only the part you can see
web.dev says any part of an element outside the viewport doesn't count toward its size. Nor does any part that is "clipped or has non-visible overflow". Clipped means cut off by the box around it, for example with overflow: hidden. The course tested it with one photo, pushed left until only its last 200 px were inside the window:
// A photo 800 by 533 px, pushed left until only its last 200 px are inside the window.const wholePhoto = 800 * 533;const insideTheWindow = 200 * 533;const chromeCounted = 106600; // its entry's size, the same in every run console.log(`The whole photo covers ${wholePhoto} px².`);console.log(`The part inside the window covers ${insideTheWindow} px².`);console.log(`Chrome counted ${chromeCounted} px².`);- Line 2 is the whole photo's area: 800 by 533 px.
- Line 3 is the part inside the window: 200 px wide, and all 533 px tall.
- Line 4 is the size Chrome's entry reported. It was the same in every run.
- Lines 6 to 8 print
The whole photo covers 426400 px².,The part inside the window covers 106600 px².andChrome counted 106600 px².
Chrome counted exactly the part inside the window: a quarter of the photo. Firefox and WebKit counted the same 106,600 px². In the playground, it's The mostly hidden photo alone. Why it matters: a huge image that's mostly off the screen can lose to a smaller one that's fully in view.
A stretched photo counts at its own size
Images have one more rule. An image file has its own width and height, its natural size. CSS can show it bigger or smaller than that. web.dev: for a resized image, "the size that gets reported is either the visible size or the intrinsic size, whichever is smaller." Intrinsic size is another name for natural size. Here's a small file, stretched:
// A small photo file, 200 by 133 px, stretched to 450 by 300 px on the page.const shown = 450 * 300;const ownSize = 200 * 133;const chromeCounted = 26600; // its entry's size, the same in every run console.log(`On the page, it covers ${shown} px².`);console.log(`At its own size, it would cover ${ownSize} px².`);console.log(`Chrome counted ${chromeCounted} px².`);- Line 2 is the area the photo covers on the page: 450 by 300 px.
- Line 3 is the area it would cover at its own size: 200 by 133 px.
- Line 4 is the size Chrome's entry reported.
- Lines 6 to 8 print
On the page, it covers 135000 px².,At its own size, it would cover 26600 px².andChrome counted 26600 px².
Stretching added nothing. It's like blowing a postcard up to poster size: the poster is bigger, but it holds no more picture than the postcard did. The spec does this in one step. If an image is shown bigger than its own size, it divides the count back down.
Shrinking works the other way. Here a big file is shown small:
// A big photo file, 1600 by 1067 px, shrunk to 300 by 200 px on the page.const shown = 300 * 200;const ownSize = 1600 * 1067;const chromeCounted = 60000; // its entry's size, the same in every run console.log(`On the page, it covers ${shown} px².`);console.log(`At its own size, it would cover ${ownSize} px².`);console.log(`Chrome counted ${chromeCounted} px².`);- Line 2 is the area on the page: 300 by 200 px.
- Line 3 is the file's own size: 1600 by 1067 px.
- Line 4 is the size Chrome's entry reported.
- Lines 6 to 8 print
On the page, it covers 60000 px².,At its own size, it would cover 1707200 px².andChrome counted 60000 px².
Again, the smaller of the two: here, the area on the screen. On a page with both photos, the stretched one looked more than twice as big. But in all three browsers, the shrunk one was the LCP: 60,000 px² against 26,600.
Why it matters: you can't make an image win by stretching it. The browser counts the picture the file really holds.
A removed element keeps its place
What if the biggest thing goes away? The course tested a splash: a heading in huge letters, "Spring sale starts now", that a script removed after one second.
// A heading in huge letters, removed by a script after 1 second. Chrome's entries, the same in every run.const entries = [{ element: "splash", size: 165711, time: 36 }];const splashStillOnPage = false; // when the course read the entries, 3 seconds in console.log(`Entries: ${entries.length}`);console.log(`LCP: ${entries.at(-1).element}, at ${entries.at(-1).time} ms`);console.log(`Was the splash still on the page? ${splashStillOnPage ? "yes" : "no"}`);- Line 2 holds Chrome's entries: just one, for the splash, at 36 ms.
- Line 3 says whether the splash was still on the page when the course read the entries, 3 seconds in. It wasn't.
- Lines 5 to 7 print
Entries: 1,LCP: splash, at 36 msandWas the splash still on the page? no
The splash stayed the LCP in all three browsers, long after it was gone. The spec still counts removed content, "To account for image carousels". A carousel is a strip of photos that come and go one at a time. It's like the judge's board: the name stays up even after its pumpkin goes home.
The spec also names the cost. It "presents problems for websites with splash screens that use large content as placeholders." Why it matters: if your splash is huge, the real content has to be bigger still to replace it.
What's excluded
Some big things on a page aren't content at all: a photo behind the text, a blurry placeholder, an image nobody can see yet. Counting them would make LCP say the page was ready when it wasn't.
Ask a friend to point out the biggest picture in your living room. They won't point at the wallpaper, though it covers every wall. They won't pick a blank canvas, or a picture still behind a curtain, either.
- In real life: The wallpaper
- In the browser: A background image that fills the whole window
- In real life: A blank canvas
- In the browser: A placeholder image with very little detail
- In real life: A picture behind a curtain
- In the browser: An element at
opacity: 0 - In real life: The framed photo on the wall
- In the browser: A real image, which can count
web.dev lists three kinds of element that Chromium-based browsers, like Chrome, leave out:
- "Elements with an opacity of 0, that are invisible to the user".
- "Elements that cover the full viewport, that are likely considered as background rather than content".
- "Placeholder images or other images with a low entropy, that likely don't reflect the true content of the page". Low entropy means little detail, like a blurred patch of colour.
A backdrop that fills the window
The course tested the second rule with one photo, used as a backdrop behind the sale text. On one page, it filled the whole 800 by 600 px window. On the other, it stopped 80 px short of each edge:
// The same photo as a backdrop on two pages. Chrome's LCP element on each, the same in every run.const lcp = { "the backdrop fills the window": "intro", "the backdrop stops 80 px from each edge": "backdrop",}; for (const [page, element] of Object.entries(lcp)) console.log(`When ${page}, the LCP is ${element}.`);- Lines 2 to 5 hold Chrome's LCP element on each page. It was the same in every run.
- Line 7 prints
When the backdrop fills the window, the LCP is intro.andWhen the backdrop stops 80 px from each edge, the LCP is backdrop.
Filling the window, the photo was skipped, and the line of words won. Moved in from the edges, the same photo won easily: 640 by 440 px, or 281,600 px². The spec's rule is exact. An element whose visible size equals the whole window's isn't a candidate. All three browsers skipped the full backdrop.
Why it matters: a hero image that fills the whole window may never be your LCP. Then the LCP is whatever sits on top of it, often a heading.
A photo nobody can see yet
Now the first rule. The course loaded the tea photo at opacity: 0, so it was fully see-through. A script showed it after one second:
// The tea photo at opacity: 0, made visible by a script after 1 second. Chrome, in ms: medians of 5 runs.const photoLoaded = 610.1;const photoCounted = 1012; console.log(`The photo finished loading at ${photoLoaded} ms, but nobody could see it.`);console.log(`It became the LCP at ${photoCounted} ms, once it could be seen.`);- Line 2 is when the photo finished loading. It comes from the LCP entry's
loadTime. - Line 3 is when Chrome counted it.
- Lines 5 and 6 print
The photo finished loading at 610.1 ms, but nobody could see it.andIt became the LCP at 1012 ms, once it could be seen.
For about 400 ms, the photo was there but invisible. Chrome waited until Asha could see it. The spec reports an element at the first paint where it "has opacity and visibility". Why it matters: a hero image that starts invisible and fades in can't count until it shows, however early it loaded.
Images with too few bytes
The third rule, low entropy, was measured in Paint, LCP, and layout shift APIs. The spec's test is simple. An image file with fewer than 0.004 bytes for each pixel it covers isn't a candidate. There, a plain SVG drawing with too few bytes for its size was never a candidate in Chrome.
Text LCP vs image LCP
An image and a block of text become the LCP at different moments. web.dev: "An element can only be considered the largest contentful element after it has rendered and is visible to the user."
An image waits for its file: "Images that haven't yet loaded aren't considered "rendered"." Once the file is in, the browser still has to paint it. Each LCP entry records both moments. Its loadTime is when the file was ready. Its renderTime is when it was painted, and that's the time LCP uses. Asha's tea photo finished loading at 608.6 ms, and was painted at 612 ms.
Text has no file to wait for, so it can count as soon as it's painted. The exception is a web font: a font file the page downloads, like the handwritten font of the sale's heading. web.dev says text using a web font during the font block period isn't rendered either.
The block period is a short wait at the start, while the font downloads. During it, the browser draws the text in an invisible stand-in font. The CSS font-display property sets how long that wait is:
font-display: blockgives a short block period. The text stays invisible while the font downloads.font-display: swapgives an extremely small one. The text shows at once in a fallback font, one the computer already has, and changes to the web font when it arrives.
A café is waiting for its new printed menu. With one rule, the menu board stays blank until the printed menu arrives. With the other, someone chalks the menu up straight away, and pins up the printed one later. With the chalk menu, customers can order at once.
- In real life: The printed menu, on its way
- In the browser: The web font, still downloading
- In real life: A blank board until it arrives
- In the browser:
font-display: block: invisible text can't count yet - In real life: The chalk menu, up straight away
- In the browser:
font-display: swap: the text counts at once, in a fallback font - In real life: Pinning up the printed menu later
- In the browser: The web font arriving: no new entry
The course's heading used a web font held back 800 ms. Here is when Chrome counted the heading, each way:
// The heading uses a web font that arrives after about 800 ms. When Chrome counted the heading, in ms: medians of 5 runs.const heading = { "font-display: block": 820, "font-display: swap": 36,}; for (const [rule, time] of Object.entries(heading)) console.log(`With ${rule}, the heading counted at ${time} ms.`);- Lines 2 to 5 hold the heading's time in Chrome for each
font-displayvalue: the median of 5 runs. - Line 7 prints
With font-display: block, the heading counted at 820 ms.andWith font-display: swap, the heading counted at 36 ms.
With block, the heading counted at 820 ms, just after the font arrived. Until then, the LCP was the line of words. With swap, the heading counted at 36 ms, in the fallback font. When the web font arrived, there was no new entry. The spec reports each element only once, at its first paint.
Why it matters: if your LCP is text in a web font, font-display decides whether it waits for the font. Here, swap moved the heading from 820 ms to 36 ms.
Here is what each kind of element waits for. Read each row across: the kind of element, what it waits for before it can count, and what Chrome recorded.
| Kind of element | What it waits for | What Chrome recorded |
|---|---|---|
An <img> | Its file to arrive, and then a paint. | The tea photo finished loading at 608.6 ms, and counted at 612 ms. |
A <video> | Its poster or its first frame, whichever comes first. | The poster counted at 416 ms. |
| Text in a font the computer already has | Only a paint. | The line of words counted at 28 ms. |
Text in a web font, with font-display: block | The font file. Until it arrives, the text is invisible. | The heading counted at 820 ms. |
Text in a web font, with font-display: swap | Only a paint, in a fallback font. | The heading counted at 36 ms. |
One more case comes up for images from another origin: a different scheme, host or port from the page. A browser may report when such an image loaded, instead of when it was painted. Exercises 5 and 6 send you to web.dev to find out why, and what changed.
How other browsers report it
Firefox and Safari report LCP too. Field data from every browser used that to collect LCP from all three. But do they follow the same rules?
Mostly, yes. On ten of the thirteen pages, all three browsers picked the same LCP element in every run. The picture shows three pages where they differed, in the element or in its time:
A photo nobody could see yet
The faded-in tea photo, in all three browsers:
// The photo at opacity: 0, shown after 1 second. When each browser counted it, in ms: medians of 5 runs.const counted = { Chrome: 1012, Firefox: 623, WebKit: 1016 }; for (const [browser, time] of Object.entries(counted)) console.log(`${browser} counted the photo at ${time} ms.`);- Line 2 holds when each browser counted the photo: the median of 5 runs.
- Line 4 prints
Chrome counted the photo at 1012 ms.,Firefox counted the photo at 623 ms.andWebKit counted the photo at 1016 ms.
Chrome and WebKit waited until the photo could be seen. Firefox counted it at 623 ms, just after it loaded, while it was still invisible. So the same page had an LCP almost 400 ms earlier in Firefox.
A photo that slid into view
This photo started outside the window, off to the right. After one second, a script slid it into place. web.dev warns about exactly this: "Only the element's initial size and position in the viewport is considered." So images that start off-screen "may not be reported."
// A photo that starts outside the window and slides in after 1 second. Each browser's LCP element, the same in every run.const lcp = { Chrome: "hero", Firefox: "intro", WebKit: "intro" }; for (const [browser, element] of Object.entries(lcp)) console.log(`${browser}'s LCP was ${element}.`);- Line 2 holds each browser's LCP element. It was the same in every run.
- Line 4 prints
Chrome's LCP was hero.,Firefox's LCP was intro.andWebKit's LCP was intro.
Firefox and WebKit never counted the photo. Their LCP stayed the line of words. Chrome counted it at 1,012 ms, once it was in view. "May not be reported" turned out to mean different things in different browsers.
A video poster
Last, the video page from earlier in this lesson, with its poster held back 400 ms:
// A video whose poster arrives after 400 ms. Each browser's LCP element, the same in every run.const lcp = { Chrome: "clip", Firefox: "clip", WebKit: "intro" }; for (const [browser, element] of Object.entries(lcp)) console.log(`${browser}'s LCP was ${element}.`);- Line 2 holds each browser's LCP element on the video page. It was the same in every run.
- Line 4 prints
Chrome's LCP was clip.,Firefox's LCP was clip.andWebKit's LCP was intro.
Chrome and Firefox counted the poster, at 416 and 420 ms. WebKit never counted the video at all. Its LCP stayed the line of words.
Smaller differences
- Fonts. On the block page, WebKit painted nothing at all until the font arrived. Its first paint came at 817 ms. Firefox counted the heading at 19 ms, long before the font arrived.
- The inset backdrop. WebKit counted it, like Chrome. Firefox skipped it in four runs of five.
- The removed splash. All three kept it as the LCP, but measured it differently: 165,711 px² in Chrome, 91,063 in Firefox and 165,782 in WebKit.
Why it matters: don't compare one browser's LCP with another's. Compare each browser with itself, before and after a change. It's like weighing yourself. Two scales can disagree, but the same scale each morning shows whether anything changed.
Will it count?
Here are eight elements you might find on a real page. Sort each one by whether it can be Chrome's LCP element. Ask three questions in turn: is it a kind that counts, can it be seen, and is it background?
- A big photo in an <img>, inside the window
- A <video> with a poster image
- A box with a background-image that doesn't fill the window
- A heading in a web font that has arrived
- A background image that fills the whole window
- A logo drawn with an inline <svg>
- A CSS gradient filling a banner
- A photo at opacity: 0 that's never shown
Sort each element by whether it can be Chrome's LCP element.
Common mix-ups
- "The first thing painted is the LCP." No. That's FCP: the bread, not the main course. Asha's first words came at 28 ms. Her LCP was the photo, at 612 ms.
- "The biggest element on the page is the LCP." No. Only the part inside the window counts: 106,600 of a photo's 426,400 px².
- "Stretching an image makes it count more." No. A photo stretched over 135,000 px² counted at its own 26,600 px².
- "Bigger letters mean a bigger block of text." No. Size is area. A short heading in big letters can cover less than a long line in small ones.
- "Removing an element takes it out of LCP." No. A splash removed after one second stayed the LCP in all three browsers.
- "Background images never count." No. Only one that fills the whole window is skipped. The same photo, 80 px in from each edge, was the LCP.
- "Every browser picks the same element." No. On three of the thirteen pages, they didn't.
Practice exercises
Answer first, then use the hints.
The shop moves the photo so its last 400 px are inside the window. What does the last line print?
// The same 800 by 533 px photo, now pushed left only until its last 400 px are inside the window.
const wholePhoto = 800 * 533;
const insideTheWindow = 400 * 533;
console.log(`The whole photo covers ${wholePhoto} px².`);
console.log(`The part inside the window covers ${insideTheWindow} px².`);The part inside the window covers 213200 px². Twice as much of the photo is in view, so LCP would count twice as much: 400 by 533 px.
The shop swaps the small file for one of 900 by 600 px, still shown at 450 by 300 px. What does the last line print?
// The stretched photo's spot, 450 by 300 px, now showing a bigger file: 900 by 600 px.
const shown = 450 * 300;
const ownSize = 900 * 600;
const counted = Math.min(shown, ownSize); // the smaller of the two
console.log(`On the page, it covers ${shown} px².`);
console.log(`LCP would count ${counted} px².`);LCP would count 135000 px². The file is now shrunk, not stretched, so it counts at the size it's shown. That would beat the shrunk photo's 60,000 px².
On the page A photo mostly off the screen, the big photo isn't Chrome's LCP. How many px² did the element that won cover?
153,600 px²: a 480 by 320 px photo, all of it in view. Only 106,600 px² of the big photo was inside the window, so the smaller photo won.
On the block page, WebKit waited for the font. Did swap help? In the playground, when was WebKit's LCP on the swap page, in ms?
819 ms. Swap didn't help in WebKit: it painted nothing until the font arrived, just as on the block page.
From which Chrome version is a render time available for cross-origin images without any special header?
Chrome 133. From that version, Chrome gives a slightly coarsened render time even without the header.
Which response header did cross-origin images need for their render time to be shown?
Timing-Allow-Origin. Without it, browsers that lack the recent change give only the image's load time.
Check your understanding
For each question, ask three things: what could be seen inside the window, how big it was counted, and when it painted.
Question 1 of 7What does the last line of Asha's candidates example print?
Read the code, then predict// Chrome's largest-contentful-paint entries for Asha's visit: the same in every run, with median times in ms. const entries = [ { element: "intro", size: 7812, time: 28 }, { element: "hero", size: 273280, time: 612 }, ]; for (const { element, size, time } of entries) console.log(`At ${time} ms, the largest so far was ${element}: ${size} px².`); const last = entries.at(-1); console.log(`LCP is the last one: ${last.element}, at ${last.time} ms.`);Choose an answer to see the explanation.
Question 2 of 7What does the last line of the stretched-photo example print?
Read the code, then predict// A small photo file, 200 by 133 px, stretched to 450 by 300 px on the page. const shown = 450 * 300; const ownSize = 200 * 133; const chromeCounted = 26600; // its entry's size, the same in every run console.log(`On the page, it covers ${shown} px².`); console.log(`At its own size, it would cover ${ownSize} px².`); console.log(`Chrome counted ${chromeCounted} px².`);Choose an answer to see the explanation.
Question 3 of 7A big photo was mostly outside the window. Which size did LCP count?
Choose an answer to see the explanation.
Question 4 of 7A background image filled the whole window. Why wasn't it Chrome's LCP?
Choose an answer to see the explanation.
Question 5 of 7A splash heading was removed from the page after a second. What happened to the LCP?
Choose an answer to see the explanation.
Question 6 of 7With font-display: block, why did Chrome count the heading so late?
Choose an answer to see the explanation.
Question 7 of 7A photo started outside the window and slid into view after a second. Which browsers reported it as LCP?
Choose an answer to see the explanation.
Key takeaways
- LCP is the time the largest image, video or block of text inside the window was painted, from the start of the visit.
- The browser writes a new candidate each time something bigger paints. The last one before the first input is the LCP.
- Size is what you can see: only the part inside the window, and for an image, the smaller of its shown and own size.
- Chrome skips elements at opacity 0, elements that fill the whole window, and images with too little detail.
- A removed element keeps its place until something bigger paints.
- An image waits for its file. Text in a web font waits for the font during its block period.
- Browsers disagree on some pages, so compare each browser with itself.
Remember the one-liner.
LCP is the main course: the largest thing you can see, timed when it's painted, until the visitor acts.
Sources
The facts on this page come from these docs:
- Largest Contentful Paint (LCP), web.dev.
- Largest Contentful Paint, the W3C.
- font-display, MDN.
Coming next: The four parts of LCP. Asha's photo painted at 612 ms. Next, you'll split that time into four parts, from the first byte to the paint, and fix the biggest part first.