Paint, LCP, and layout shift APIs
The APIs behind FCP, LCP, and CLS, plus Element Timing for the elements you care about. One test page, left alone, clicked early, and given a low-content hero, recorded in Chrome, Firefox and WebKit.
- 01Read the first paintsTell first-paint from first-contentful-paint, and know which moment each browser's time stands for.
- 02Turn entries into LCP and CLSTake the last LCP entry before the first input, and the worst burst of layout shifts that nobody caused.
- 03Read what moved, and time what mattersRead a layout shift's sources and score, and mark the elements you care about with
elementtiming.
The entries behind FCP, LCP and CLS
Asha opens Leaf and Kettle's mint tea page on her phone. The headline appears first, with two small photos. A moment later, a banner saying "Free delivery this week" pops in near the top. It pushes everything below it down. Last of all, the big photo of a glass of mint tea fills its space.
To Asha, the page just loaded. But three questions decide whether it felt fast and steady:
- When did she first see something?
- When did the main thing, the big photo, show up?
- Did anything jump while she was looking?
Here is her screen at those three moments:
Look at the middle screen. The banner pushed the button, the empty space for the big photo, and the small photos down. Each screen answers one of her questions.
You already know the three metrics that answer them.
- FCP, first contentful paint: when the first text or image was drawn. It's one of the supporting metrics.
- LCP, largest contentful paint: when the largest text or image was drawn.
- CLS, cumulative layout shift: a score for how much things jumped. LCP and CLS are two of the Core Web Vitals.
But the browser doesn't measure a metric in one go. While the page loads, it writes down small facts, one at a time. Each metric is worked out from those facts.
In The performance timeline, those facts were lines in the shop's logbook. Each line is an entry, with a type, a name and a time. Four types of entry sit behind Asha's three questions:
paint: the first things drawn on the screen. The Paint Timing API writes them, and FCP comes from them.largest-contentful-paint: the largest thing drawn so far. LCP comes from these.layout-shift: something on the screen moved. The Layout Instability API writes them, and CLS comes from them.element: an element you named was drawn. The Element Timing API writes them. No metric uses them; they're for you.
Three of the four are "observers only" in the Timing Entry Names Registry. That's the list of entry types you met in The performance timeline. "Observers only" means getEntriesByType skips them. So you read them the way that lesson taught, with buffered observers started early. The course's test page did that in its first script. Shortened, it looks like this:
// The mint tea page's first script: one buffered observer for each kind of entry.for (const type of ["paint", "largest-contentful-paint", "layout-shift", "element"]) { const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) console.log(type, entry.startTime); }); observer.observe({ type, buffered: true });}- Line 2 goes through the four types, one at a time.
- Lines 3 to 5 make an observer for that type. It's Ravi, who taps you on the shoulder. Each time he does, line 4 prints each new entry's type and
startTime. - Line 6 starts watching.
buffered: truealso brings the entries written before this line ran.
The real page saved each entry instead of printing it. It also checked supportedEntryTypes first, so browsers skipped the types they don't write.
The course built the mint tea page, loaded it in Chrome, and left it alone. Here is every entry Chrome's observers were told about, on one timeline.
Read it from left to right. It answers all three questions:
- At 68 ms, the headline and the small photos were drawn. That gave the paint entry for FCP. It also gave a first LCP entry, for one of the small photos.
- At 423.6 ms, the banner pushed things down. That gave one layout shift, with a value of 0.0364.
- At 740 ms, the big photo was drawn. It was the largest yet, so it got the last LCP entry. LCP is 740 ms.
At Leaf and Kettle, the staff write a line in the logbook each time something happens. A delivery, a spill, a boiled kettle: each one gets a line. Nobody writes "today went well". At closing time, the manager reads the lines. She works out the day's numbers by a few fixed rules. The lines are the facts. The numbers come from the rules.
- In real life: Each line, written as something happens
- In JavaScript: Each entry the browser writes as the page loads
- In real life: The manager's rules at closing time
- In JavaScript: How a metric is worked out from the entries
- In real life: The day's numbers
- In JavaScript: FCP, LCP and CLS
Each API writes raw entries while the page loads. A metric is a rule applied to those entries. FCP is the time of the first-contentful-paint entry. LCP is the last LCP entry written before the visitor's first input. CLS is the worst burst of layout shifts that the visitor didn't cause.
The rules matter, because what the visitor does changes the entries. So the course loaded the same page three ways, in Chrome, Firefox and WebKit:
- Left alone: nobody touched the page.
- Clicked early: the course clicked the page's More about this tea button. It did so about a quarter of a second in, before the big photo arrived. That's Asha tapping it while she waits.
- SVG hero: the big photo was swapped for a plain SVG drawing of the same size. SVG is a picture format made of shapes written as text. This one was only 164 bytes.
Each section below follows one type of entry.
Paint Timing
Start with Asha's first question: when did she first see something? First, two words. To paint is to draw the page's pixels on the screen. The browser paints in frames. A frame is one full picture of the page, drawn and then shown. It's like one page of a flip book.
Early in the morning, you walk past Leaf and Kettle. The lights come on inside, and the window starts to glow: something is happening. A minute later, you can read the menu board and see the teapots on the shelf. The first glow and the first thing you can look at are two different moments.
- In real life: The window starts to glow
- In JavaScript:
first-paint: the first time anything is drawn, even just a background colour - In real life: You can read the menu board, or see a teapot
- In JavaScript:
first-contentful-paint: the first text or image - In real life: The time on the shop clock at each moment
- In JavaScript: Each entry's
startTime
Paint Timing gives each moment an entry, named after it. The spec says these entries answer "is it happening?" for the visitor. There are two:
first-paintis the first time the browser draws anything for the page. The plain background a browser shows on its own doesn't count. A background colour the page sets does.first-contentful-paintis the first time it draws text or an image from the page. Contentful means it has content you can read or look at.
Here are Chrome's two paint entries on the left-alone load.
// Chrome's paint entries on the left-alone load: [name, startTime in ms].const paints = [["first-paint", 68], ["first-contentful-paint", 68]]; for (const [name, time] of paints) { console.log(`${name} came at ${time} ms.`);}- Line 2 holds the two entries Chrome wrote. Each has a name and a
startTime, in ms. - Lines 4 to 6 print a sentence for each:
first-paint came at 68 ms.andfirst-contentful-paint came at 68 ms.
Both came at 68 ms. The very first frame already had the headline in it. The page sets no background colour, so nothing came before the text. On a page that paints a coloured background first, first-paint can come earlier. So the answer to Asha's first question: she first saw words at 68 ms.
Not every browser reports both
The spec makes first-paint optional. It says browsers "should at the very least mark first contentful paint". The three browsers did exactly that.
// The paint entries each engine reported on the left-alone load.const names = { Chrome: ["first-paint", "first-contentful-paint"], Firefox: ["first-contentful-paint"], WebKit: ["first-contentful-paint"],}; for (const [engine, list] of Object.entries(names)) { console.log(`${engine} reported ${list.join(" and ")}.`);}- Lines 3 to 5 hold the names of the entries each browser wrote.
- Line 9 prints
Chrome reported first-paint and first-contentful-paint., thenFirefox reported first-contentful-paint.andWebKit reported first-contentful-paint.
Why it matters: build your reports on first-contentful-paint, which every browser gave. Treat first-paint as a bonus that may be missing.
Drawn, then shown
A frame isn't on the screen the moment the browser finishes drawing it. In the same way, a sign painted in the back room isn't yet in the window. Newer browsers can give both moments:
paintTime: when the browser finished its drawing work for the frame.presentationTime: when the frame was shown on the screen. The spec lets each browser decide exactly how to mark it.
The entry's startTime is the presentation time, if the browser gives one. Otherwise it's the paint time. Here is first-contentful-paint in each browser:
// first-contentful-paint, left alone: [paintTime, presentationTime, startTime] in ms.const fcp = { Chrome: [53, 68, 68], Firefox: [100, null, 100], WebKit: [null, null, 117] };const at = (time) => (time === null ? "not given" : `${time} ms`); for (const [engine, [drawn, shown, start]] of Object.entries(fcp)) { console.log(`${engine}: paintTime ${at(drawn)}, presentationTime ${at(shown)}, startTime ${start} ms.`);}- Line 2 holds each browser's three times.
nullmeans the browser didn't give that time. - Line 3 turns a missing time into the words "not given".
- Line 6 prints one sentence per browser. The first is
Chrome: paintTime 53 ms, presentationTime 68 ms, startTime 68 ms. - Then come
Firefox: paintTime 100 ms, presentationTime not given, startTime 100 ms.andWebKit: paintTime not given, presentationTime not given, startTime 117 ms.
Chrome finished drawing at 53 ms. It showed the frame at 68 ms, 15 ms later. Its startTime is the later of the two. Firefox gave only the paint time, so its startTime is when drawing finished. WebKit's paint entry gave neither, so it doesn't say which moment its 117 ms marks.
Why it matters: Chrome's FCP includes the last step to the screen. Firefox's stops before it. So FCP from two browsers isn't quite the same measurement.
The Largest Contentful Paint API
Asha's second question is harder: when did the main thing show up? At 68 ms she saw a headline, not the photo she came for. The LCP spec says first paints don't "take into account the importance of the painted content".
The browser can't know which element matters most to Asha. So it uses size instead. The spec says the largest paint "is likely to signify a meaningful event from the user's perspective". But while the page loads, more may still be on its way. So the browser keeps calling out the largest one so far.
At a school fun run, a commentator watches the runners come onto the track. Each time one is bigger than everyone before, she calls it out. She shouts "Biggest so far: number 4!" to the crowd. She never calls out a smaller runner. When the whistle blows, she stops. The last name she called is the answer.
- In real life: A runner bigger than everyone before comes onto the track
- In JavaScript: An element larger than any before is drawn
- In real life: "Biggest so far: number 4!"
- In JavaScript: A
largest-contentful-paintentry - In real life: The whistle that ends the race
- In JavaScript: The visitor's first tap, click, key press or scroll
- In real life: The last name called before the whistle
- In JavaScript: LCP
Each call is an entry of type largest-contentful-paint, often called an LCP candidate. Its size is the area the element covers on the screen. That's its visible width times its height, in square pixels (px²). Here are Chrome's calls on the left-alone load:
// Chrome's LCP entries on the left-alone load: [startTime, size, element].const entries = [[68, 10800, "tao"], [740, 86400, "hero"]]; for (const [time, size, element] of entries) { console.log(`At ${time} ms, the largest so far was #${element}: ${size} px².`);}const [time, , element] = entries.at(-1);console.log(`LCP is the last one: #${element}, at ${time} ms.`);- Line 2 holds the two entries Chrome wrote. Each says when, how big, and which element, by its
id. - Lines 4 to 6 print one sentence per entry:
At 68 ms, the largest so far was #tao: 10800 px².andAt 740 ms, the largest so far was #hero: 86400 px². - Line 7 takes the last entry. Line 8 prints
LCP is the last one: #hero, at 740 ms.
#tao is the mint leaves photo, 120 × 90 px. #hero is the big photo, 360 × 240 px. The headline was drawn in the same frame as the small photos, at 68 ms. The spec looks at everything drawn in a frame. It writes at most one entry, for the largest. And it writes it only if it beats the last call. So the headline lost to the photo.
So the answer to Asha's second question is 740 ms. Firefox and WebKit called out a few smaller elements first. But left alone, every browser's last call was the big photo.
Why it matters: LCP is the last entry, not the first. Code that keeps the first entry would report a small photo at 68 ms. It would miss the big one at 740 ms.
The first input ends the race
The commentator stops at the whistle. LCP stops at the visitor's first input. The spec gives the reason. "The algorithm terminates whenever scroll or input events occur, since those are likely to introduce new content into the website".
In plain words: once Asha taps, clicks, types or scrolls, new things on the screen are probably her doing. They're no longer just the page loading. Just moving the mouse doesn't count.
Now say Asha taps More about this tea while she waits, before the big photo arrives. The course clicked it at 244.7 ms:
// Chrome's LCP entries when the button was clicked at 244.7 ms: [startTime, size, element].const entries = [[36, 10800, "tao"]]; const [time, size, element] = entries.at(-1);console.log(`LCP is #${element}, at ${time} ms: ${size} px².`);- Line 2 holds the only entry Chrome wrote: the small photo, at 36 ms. Each load was a new visit, so this one's first frame came sooner than 68 ms.
- Line 4 takes the last entry, and line 5 prints
LCP is #tao, at 36 ms: 10800 px².
The click didn't make the page faster. The big photo still appeared, at 720 ms, as Element Timing recorded. But the race was over, so no entry was written for it. Firefox and WebKit stopped too. The spec lists this as one of its own limits. The API "will not capture the main content if the user input occurs before the main content is displayed".
Why it matters: look closely at a surprisingly fast LCP in data from real visitors. It can mean the visitor tapped early. Check which element the entry names. If it's a small photo or a headline, LCP may have stopped before the main content arrived.
Images with too few bytes don't count
One more rule decides what can be called out at all. Picture a huge poster that's one flat colour. It fills the wall, but there's nothing to look at. Images like that are usually backgrounds or colour fades, called gradients. The spec says they "are not considered as LCP candidates".
It spots them by their bytes. An image file with fewer than 0.004 bytes for each pixel it covers isn't a candidate. The course tried this with the SVG hero. It's a plain drawing of 164 bytes, shown at the same 360 × 240 px as the photo.
// An image needs at least 0.004 bytes for each pixel it covers to be an LCP candidate.const pixels = 360 * 240;const needed = pixels * 0.004;const heroes = { jpeg: 102809, svg: 164 }; for (const [hero, bytes] of Object.entries(heroes)) { console.log(`The ${hero} hero has ${bytes} bytes and needs ${needed}: ${bytes < needed ? "skipped" : "a candidate"}.`);}- Line 2 is the number of pixels each hero covers on the screen.
- Line 3 is the fewest bytes an image that size needs: 345.6.
- Line 4 holds each hero's real size in bytes, as the server sent it.
- Line 7 prints
The jpeg hero has 102809 bytes and needs 345.6: a candidate.andThe svg hero has 164 bytes and needs 345.6: skipped.
Chrome followed the rule. It never wrote an entry for the SVG hero. So its LCP stayed at the small photo. Firefox and WebKit called out the SVG hero anyway. So the same page had a different LCP element in different browsers.
Why it matters: before you compare LCP across browsers, check that they named the same element. Each LCP entry names its element in element, and an image's address in url. How LCP works covers which elements can be LCP, and how to make it faster.
The Layout Instability API
Asha's third question: did anything jump? Yes. At 400 ms the page added the banner above the button. It had kept no space for it. So the button, the big photo's space and both small photos moved down to make room. Chrome wrote down the jump at 423.6 ms.
The Layout Instability API notices moves like this. For each frame in which visible content moved, it writes a layout-shift entry. Three parts of each entry matter here:
value: how bad the jump was, as a score.sources: the elements that moved, each with its place before and after.hadRecentInput: whether the visitor had just clicked, tapped or typed.
What moved
Each source has two rectangles. One shows where the element was before the frame. The other shows where it is now. A rectangle is four numbers: x, y, width and height, in px from the top left of the screen. Chrome wrote one shift. Here is the y, the distance from the top, for each of its sources.
// Chrome's layout shift at 423.6 ms, left alone: each source's [id, top before, top after] in px.const sources = [["hero", 148, 221], ["tao", 396, 469], ["no-tao", 494, 567], ["more", 119, 192]]; for (const [id, before, after] of sources) { console.log(`#${id} moved down ${after - before} px, from ${before} to ${after}.`);}- Line 2 holds each source's
id, its top before, and its top after. - Line 5 subtracts to find how far each one moved. It prints
#hero moved down 73 px, from 148 to 221. - Then come
#tao moved down 73 px, from 396 to 469.,#no-tao moved down 73 px, from 494 to 567.and#more moved down 73 px, from 119 to 192.
Everything moved down by the same 73 px. That's the room the banner took. But look at what's missing: the banner itself isn't a source. The spec explains why. When a new element pushes content down, sources report "only the shifted elements, and not the inserted element".
Why it matters: sources tells you what moved, not what pushed it. To find the cause, look just above the highest element in the list. Here, that was the button, #more, and the banner went in right above it.
How bad was the jump?
Someone bumps the wall in the café, and some pictures slide down. How bad was it? You'd look at two things. First, how much of the wall the moving pictures covered, where they hung and where they ended up. Second, how far they slid. Big pictures sliding a long way make a bad bump.
- In real life: The wall
- In JavaScript: The screen, which the spec calls the viewport
- In real life: How much of the wall the pictures covered, where they hung and where they ended up
- In JavaScript: The impact fraction
- In real life: How far they slid, compared with the wall's longest side
- In JavaScript: The distance fraction
- In real life: The two, multiplied
- In JavaScript: The entry's
value
The spec multiplies the same two numbers.
- The impact fraction is the area the moved elements covered, before or after. It's given as a share of the screen's area.
- The distance fraction: the farthest any of them moved, as a share of the screen's longer side.
Rendering and runtime tools worked this out for a strip as wide as the screen. The course's phone screen was 412 × 823 px. Here is the banner's shift:
// The same shift, on the course's 412 × 823 px phone screen.const screenArea = 412 * 823;const coveredArea = 138930; // px² the four sources covered, before or afterconst impact = coveredArea / screenArea;const distance = 73 / 823; // 73 px down, out of the screen's longer sideconsole.log(`impact ${impact.toFixed(2)} × distance ${distance.toFixed(3)} = ${(impact * distance).toFixed(3)}`);- Line 2 is the screen's area.
- Line 3 is the area the four sources covered, in their old place or their new one, counting any overlap once. The course added it up from the entry's rectangles, which the page rounded to whole pixels.
- Line 4 is the impact fraction. Line 5 is the distance fraction: 73 px out of the longer side, 823 px.
- Line 6 multiplies them and prints
impact 0.41 × distance 0.089 = 0.036.
Chrome's own value was 0.0364: the same, to three decimal places. The moved elements covered 41% of the screen. But they moved less than 9% of its height. The spec's note puts it this way: "a large element which moves only a small distance can have a low impact on the perceived instability of the page".
Why it matters: keep space for the banner before it arrives. Then nothing moves, so no entry is written at all.
A jump right after a click doesn't count
In What is web performance?, a steady page was a café table that nobody bumps. But some bumps are the visitor's own doing.
Your cup moves just after you reach across the table for the sugar. That's your doing, and nobody blames the waiter. But if the waiter bumps the table while you're reading, that one is the café's fault.
- In real life: You reach across the table for the sugar
- In JavaScript: A click, tap or key press
- In real life: Your cup moves a moment later
- In JavaScript: A layout shift less than 500 ms after the input
- In real life: Nobody blames the waiter
- In JavaScript:
hadRecentInput: true, so CLS skips the shift - In real life: The waiter bumps the table while you read
- In JavaScript: A shift with
hadRecentInput: false, which counts
So each entry carries hadRecentInput. The spec sets it to true if the last click, tap or key press was recent. Recent means less than 500 ms before the shift. Moving the mouse doesn't count as input here.
The early click opened a hidden paragraph about fresh mint leaves. Here are Chrome's shifts on that load:
// Chrome's layout shifts when the button was clicked at 244.7 ms: [startTime, value, hadRecentInput].const shifts = [[255.6, 0.0388, true], [410, 0.0419, true]]; for (const [time, value, recentInput] of shifts) { const after = (time - 244.7).toFixed(1); console.log(`A shift of ${value}, ${after} ms after the click: hadRecentInput is ${recentInput}.`);}- Line 2 holds Chrome's two shifts: when, how big, and the flag.
- Line 5 works out how long after the click each one came.
- Line 6 prints
A shift of 0.0388, 10.9 ms after the click: hadRecentInput is true.andA shift of 0.0419, 165.3 ms after the click: hadRecentInput is true.
The first shift was the click's own work: the new paragraph pushed the photos down. The second was the banner arriving and pushing everything down again. It had nothing to do with the click. But it came 165.3 ms after it, well inside the 500 ms. So it carries the flag too.
Why it matters: a shift that would have counted can hide behind a click. On this load, the banner's jump was real, but it never reached CLS.
From shifts to CLS
CLS is worked out from these entries. web.dev calls it "a measure of the largest burst of layout shift scores". Two rules turn that into code:
- Skip every shift with
hadRecentInput. The visitor expected those. - Group the rest into bursts, called session windows. A shift joins the current window if it came less than 1 second after the one before. The window must also still be under 5 seconds long. CLS is the window with the largest total.
Core Web Vitals met these windows. Here they are as code, with Chrome's shifts from both loads.
// Chrome's layout shifts as [startTime, value, hadRecentInput].const loads = { "Left alone": [[423.6, 0.0364, false]], "Clicked early": [[255.6, 0.0388, true], [410, 0.0419, true]],}; function cls(shifts) { let worst = 0, burst = 0, burstStart = 0, previous = 0; for (const [time, value, recentInput] of shifts) { if (recentInput) continue; if (burst > 0 && time - previous < 1000 && time - burstStart < 5000) burst += value; else [burst, burstStart] = [value, time]; previous = time; worst = Math.max(worst, burst); } return worst;}for (const [load, shifts] of Object.entries(loads)) console.log(`${load}, CLS is ${cls(shifts)}.`);- Lines 2 to 5 hold Chrome's shifts: one when left alone, two when clicked early.
- Line 8 starts four numbers at 0. They are the worst burst so far, the current burst, when it started, and when the previous shift came.
- Line 10 skips any shift with recent input.
- Line 11 adds the shift to the current burst if it came soon enough. Otherwise, line 12 starts a new burst with it.
- Line 14 keeps the worst burst so far, and line 16 returns it.
- Line 18 prints
Left alone, CLS is 0.0364.andClicked early, CLS is 0.
Left alone, the banner's one shift is the whole CLS. Clicked early, both shifts were skipped. So CLS is 0, even though the page jumped twice.
LCP and layout shifts both have a rule about input. The two are easy to mix up. Read each row across: a question, then the answer for each type of entry.
| The question | LCP entries | Layout shift entries |
|---|---|---|
| Which inputs matter? | The first tap, click, key press or scroll. | A tap, click or key press. Scrolling doesn't count. |
| What happens after the input? | No more entries are written for this page. | Shifts are still written. Those in the next 500 ms carry hadRecentInput: true. |
| What does the metric do? | LCP is the last entry written before the input. | CLS skips the flagged shifts and works with the rest. |
| What did the early click do? | Chrome's LCP stayed at the small photo, at 36 ms. | Both shifts were flagged, so Chrome's CLS was 0. |
One last thing. As The performance timeline found, Firefox and WebKit don't write layout-shift entries at all. Their visitors have no CLS. Report it as missing, not as 0.
Element Timing
LCP picks the element for you, by size. But the element you care about may not be the largest. On the mint tea page, it might be the headline, or the photo of the teapot. Element Timing lets you choose. It's like telling the waiter which dishes you're waiting for. You ask them to note when each one reaches your table.
The spec's reason: web developers know which elements matter on their own pages. So they should be able to tell the browser.
You name an element with the elementtiming attribute. These two lines are from the mint tea page:
<h1 id="headline" elementtiming="headline">Mint tea</h1>
<img id="hero" elementtiming="hero" src="/hero.jpg" width="360" height="240" alt="A glass of mint tea">- Line 1 names the headline
headline. - Line 2 names the big photo
hero.
When a named element is first drawn, the browser writes an element entry for it. The name you gave comes back as its identifier. Two times say when things happened:
loadTime: for an image, when its file finished loading.renderTime: when the element was drawn on the screen.
// Chrome's Element Timing entries, left alone: [identifier, renderTime, loadTime] in ms.const entries = [["headline", 68, 0], ["hero", 740, 722.7]]; for (const [identifier, render, load] of entries) { console.log(`${identifier} has loadTime ${load} and renderTime ${render}.`);}- Line 2 holds Chrome's two entries: the name, then
renderTime, thenloadTime. - Line 5 prints
headline has loadTime 0 and renderTime 68.andhero has loadTime 722.7 and renderTime 740.
The headline's loadTime is 0, because text has nothing to download. The big photo finished loading at 722.7 ms. It was on the screen about 17 ms later, at 740 ms. That's the same 740 ms as its LCP entry: both APIs timed the same paint.
Photos from another origin
The two small photos came from another origin: a different scheme, host or port from the page. In Navigation Timing and Resource Timing, a file from another origin hid most of its timings. It showed them only if its server sent a Timing-Allow-Origin header. A header is a short note the server sends along with a file. The course sent that header with one photo only:
// The two small photos, both from another origin. Only photo-tao came with Timing-Allow-Origin.const entries = [["photo-tao", 68, 47.5], ["photo-no-tao", 68, 48]]; for (const [identifier, render, load] of entries) { console.log(`${identifier} has loadTime ${load} and renderTime ${render}.`);}- Line 2 holds the two photos' entries.
photo-taocame with the header, andphoto-no-taowithout it. - Line 5 prints
photo-tao has loadTime 47.5 and renderTime 68.andphoto-no-tao has loadTime 48 and renderTime 68.
The header made no difference. Checks like Timing-Allow-Origin don't work here, the spec explains. That's "due to same-origin and cross-origin images being rendered at the same time". Images from every origin are drawn together, in the same frames. So the header can't hide when one appeared. Instead, the spec advises rounding this time to at least 4 ms. That way it doesn't give away how long each image took to decode, or unpack into pixels.
Why it matters: in Chrome, you can time photos from a CDN or another site. A CDN is a network of servers that keeps copies of your files close to your visitors. The timing works even when the server doesn't send the header. Firefox and WebKit don't support Element Timing.
The whole page, in every browser
Here is the mint tea page in all three browsers, loaded all three ways. Each row is one type of entry, and each mark is one entry, at its time.
2 paint entries. 2 LCP candidates, and the last one, #hero at 740 ms, is LCP. CLS is 0.0364. Nobody clicked.
Try Chrome, clicked early. The thin line in each row marks the click, and the shifts after it say "recent input". Then try Firefox: two of its rows say "not supported".
Which API reports it?
Each item below is something you might want to know about a page load. Sort it by the API that writes the entry you'd read. Ask yourself: is it a first paint, a "largest so far", something that moved, or an element you named?
- When the first text or image appeared
- When anything at all was first drawn, even a background colour
- Ever bigger elements, until the first tap, click, key press or scroll
- Skips images with too few bytes for the space they cover
- Flags movements that came less than 500 ms after a click
- Lists the elements that moved, with their place before and after
- Times only the elements you named with an attribute
- Says when a marked headline was drawn, with a loadTime of 0
Sort each item by the API that writes its entry.
Common mix-ups
- "LCP waits for the biggest element." No. It stops at the first tap, click, key press or scroll. Clicked early, Chrome's LCP was 36 ms, not 740 ms.
- "Any image can be LCP." No. Chrome skips images with fewer than 0.004 bytes for each pixel they cover. The 164-byte SVG hero never got an entry.
- "CLS adds up every shift." No. It skips shifts that came less than 500 ms after a click, tap or key press. Then it keeps only the worst burst.
- "Every browser reports first-paint." No. The spec makes it optional, and only Chrome reported it.
- "A layout shift's sources are what caused it." No. They're what moved. The banner that pushed everything down wasn't in the list.
- "Without Timing-Allow-Origin, an image from another origin has no render time." No. Chrome gave both small photos a renderTime of 68 ms.
Practice exercises
Answer first, then use the hints.
Say the click had come after the big photo appeared, at the 720 ms Element Timing recorded. What does the code print?
// Chrome's LCP entries if the click had come after the big photo appeared: [startTime, size, element].
const entries = [[36, 10800, "tao"], [720, 86400, "hero"]];
const [time, size, element] = entries.at(-1);
console.log(`LCP is #${element}, at ${time} ms: ${size} px².`);LCP is #hero, at 720 ms: 86400 px². The race only ends at the click. If the click comes after the big photo, the photo's entry is already written, and it's the last one.
Say the banner's shift hadn't been flagged with recent input. What does the second line print?
// Chrome's layout shifts as [startTime, value, hadRecentInput].
const loads = {
"Left alone": [[423.6, 0.0364, false]],
"Clicked early": [[255.6, 0.0388, true], [410, 0.0419, false]],
};
function cls(shifts) {
let worst = 0, burst = 0, burstStart = 0, previous = 0;
for (const [time, value, recentInput] of shifts) {
if (recentInput) continue;
if (burst > 0 && time - previous < 1000 && time - burstStart < 5000) burst += value;
else [burst, burstStart] = [value, time];
previous = time;
worst = Math.max(worst, burst);
}
return worst;
}
for (const [load, shifts] of Object.entries(loads)) console.log(`${load}, CLS is ${cls(shifts)}.`);Clicked early, CLS is 0.0419. Only the banner's shift counts. It's alone in its burst, so CLS is its value.
Left alone, how many LCP candidates did Firefox write?
4: #more, #headline, #tao, #hero. Each one was larger than the one before.
Clicked early, which element was WebKit's LCP?
#no-tao, at 24 ms. Like Chrome, WebKit stopped before the big photo arrived.
A layout shift's sources list the elements that moved. At most how many does the Layout Instability spec allow in one entry?
Five. The spec reports up to five of the elements that moved, sorted by the area they affected, largest first. The banner's shift on the clicked load listed exactly five.
A page shows a large splash image, removes it, then shows a smaller hero photo. Does the LCP API write an entry for the hero photo?
No. The spec still counts removed content, so the splash image stays the largest so far. A new entry is written only if something larger is drawn later. The spec lists this as a problem for pages with splash screens.
Check your understanding
For each question, ask three things: which entries were written, whether an input came first, and which browser wrote them.
Question 1 of 8What does the clicked-early LCP example print?
Read the code, then predict// Chrome's LCP entries when the button was clicked at 244.7 ms: [startTime, size, element]. const entries = [[36, 10800, "tao"]]; const [time, size, element] = entries.at(-1); console.log(`LCP is #${element}, at ${time} ms: ${size} px².`);Choose an answer to see the explanation.
Question 2 of 8What does the second line of the CLS example print?
Read the code, then predict// Chrome's layout shifts as [startTime, value, hadRecentInput]. const loads = { "Left alone": [[423.6, 0.0364, false]], "Clicked early": [[255.6, 0.0388, true], [410, 0.0419, true]], }; function cls(shifts) { let worst = 0, burst = 0, burstStart = 0, previous = 0; for (const [time, value, recentInput] of shifts) { if (recentInput) continue; if (burst > 0 && time - previous < 1000 && time - burstStart < 5000) burst += value; else [burst, burstStart] = [value, time]; previous = time; worst = Math.max(worst, burst); } return worst; } for (const [load, shifts] of Object.entries(loads)) console.log(`${load}, CLS is ${cls(shifts)}.`);Choose an answer to see the explanation.
Question 3 of 8Chrome's first-contentful-paint entry had a paintTime of 53 ms and a presentationTime of 68 ms. What was its startTime?
Choose an answer to see the explanation.
Question 4 of 8A visitor taps a page before its main photo appears. What happens to LCP?
Choose an answer to see the explanation.
Question 5 of 8Why did Chrome never write an LCP entry for the 164-byte SVG hero, while Firefox and WebKit did?
Choose an answer to see the explanation.
Question 6 of 8Why don't layout shifts that come right after a click count toward CLS?
Choose an answer to see the explanation.
Question 7 of 8A layout-shift entry lists #hero, #tao, #no-tao and #more as its sources. What does that tell you?
Choose an answer to see the explanation.
Question 8 of 8A photo from another origin, sent without Timing-Allow-Origin, was marked with elementtiming. Did Chrome say when it appeared?
Choose an answer to see the explanation.
Key takeaways
- Each API writes raw entries while the page loads. FCP, LCP and CLS are rules applied to them.
- All three browsers wrote first-contentful-paint. first-paint is optional, and only Chrome wrote it.
- startTime is the presentation time when the browser gives one, and the paint time otherwise.
- LCP is the last largest-contentful-paint entry before the first tap, click, key press or scroll. An early click can make it look fast.
- Chrome skips images with fewer than 0.004 bytes per pixel, so different browsers can name different LCP elements.
- A layout shift's value is its impact fraction times its distance fraction. Its sources say what moved, not what pushed it.
- CLS skips shifts with hadRecentInput and keeps the worst burst. Only Chrome writes layout shifts.
- Element Timing times the elements you name, images from other origins included. Only Chrome supports it.
Remember the one-liner.
The browser writes entries, and the metrics are rules: LCP is the last call before the first input, and CLS is the worst burst nobody caused.
Sources
The facts on this page come from these specs and docs:
- Timing Entry Names Registry, W3C.
- Paint Timing, W3C.
- Largest Contentful Paint, W3C.
- Event Timing, W3C.
- Layout Instability, WICG.
- Cumulative Layout Shift (CLS), web.dev.
- Element Timing, W3C.
Coming next: Event Timing and INP. The Event Timing API writes an entry for each slow interaction. INP is a rule applied to those entries, the same way LCP and CLS were here.