completefrontend
Open lab My progress
THE WEB PERFORMANCE FIELD GUIDE

The web-vitals library

Measure LCP, INP, and CLS the way Chrome does, with attribution that points at the cause. web-vitals 6.2.3 on a tea shop in Chrome, Firefox and WebKit, with every report it sent: when each callback ran, what attribution said, and what changed after a back/forward cache restore, a prerender, and a background tab.

By the end, you can
  • 01
    Say when each report arrivesCall onFCP, onTTFB, onLCP, onINP and onCLS, and explain why INP and CLS only report when the page is hidden.
  • 02
    Find the cause with attributionRead the parts of LCP and INP in the attribution build, and the element and script they name.
  • 03
    Handle visits that aren't a plain loadKnow what reportAllChanges, a back/forward cache restore, a prerender and a background tab do to your reports.

The metrics, measured the way Chrome does

Asha opens the mint tea page at Leaf and Kettle, on her laptop. The heading appears first, then the photo of the tea. She clicks Add to cart. A moment later she clicks a link to the next page, and she's gone.

Here is her visit, moment by moment, as the course recorded it in Chrome:

Asha's visit in Chrome, moment by moment. At 6.4 ms, the first byte arrives: a blank screen. TTFB 6.4, sent at 332.4 ms. At 72 ms, the heading appears: the heading, with an empty space where the photo goes. FCP 72, sent at 76.5 ms. At 344 ms, the photo appears: the heading and the mint tea photo. LCP 344, sent at 2669.2 ms. At 637.9 ms, a banner pushes it down: a Free delivery banner above the photo, pushing it down. CLS 0.0468, sent at 2689.9 ms. At 1879.3 ms, she clicks Add to cart: Cart: 1 under the Add to cart button. INP 184, sent at 2689.8 ms. At 2677.7 ms, she clicks a link away: the next page. INP and CLS were sent just after she left.6.4 msblankthe first bytearrivesTTFB 6.4sent at332.4 ms72 msthe heading, with an empty space where the photo goesthe headingappearsFCP 72sent at76.5 ms344 msthe heading and the mint tea photothe photoappearsLCP 344sent at2669.2 ms637.9 msa Free delivery banner above the photo, pushing it downa bannerpushes it downCLS 0.0468sent at2689.9 ms1879.3 msCart: 1 under the Add to cart buttonshe clicksAdd to cartINP 184sent at2689.8 ms2677.7 msthe next pageshe clicksa link awayINP and CLSsent just after
Asha's screen at six moments, evenly spaced, not to scale. The screens are real frames Chrome captured on a second run of the same visit. Under each one: the report that moment gave, and when the library sent it. Shaded: the reports that waited until after her click.

Five moments gave a number each, even the banner that pushed the photo down. But they weren't all sent at once. LCP waited for her click, and INP and CLS waited until she left.

Nobody from the shop was watching. So how fast did the page feel to Asha? And how did it feel to everyone else who visited today, each on their own phone or laptop?

Module 11 gave you the browser's raw tools for this. The browser writes everything it times into one logbook, the performance timeline. Its LCP entries are a commentator calling out the biggest runner so far. Event Timing times each click like a bell rung at a café counter, from the ring to the cup on the table.

From those entries, you could work out LCP, INP and CLS yourself. But every metric comes with fiddly rules:

  • When does the commentator stop calling out runners?
  • Which slow click counts for INP, and which ones are left out?
  • Which layout shifts are grouped together for CLS?
  • What if Asha switches tabs halfway, or comes back with the Back button?

Get one wrong, and your numbers won't match Chrome's. Is there a way to get Chrome's numbers without writing those rules yourself?

Yes. Google's Chrome team publishes a JavaScript library called web-vitals. Its README calls it "a tiny (~3K, brotli'd), modular library for measuring all the Web Vitals metrics on real users". Brotli is a way to compress text files before they travel, so the library adds about 3 kilobytes to a page. And it measures them "in a way that accurately matches how they're measured by Chrome."

Collecting numbers like these from real visitors is called real-user monitoring, or RUM. Real-user monitoring explains the idea. This module builds it, starting with the library.

Real-life analogyA timing kit from head office

Leaf and Kettle has cafés in many towns. Head office wants every café to time its customers the same way. So it doesn't ask each manager to invent rules for every odd case. It posts each café the same timing kit, with the rules already inside. The manager switches it on and reads the cards it hands back.

In real life: Head office's way of timing a visit
In JavaScript: How Chrome measures LCP, INP and CLS
In real life: The kit in the box
In JavaScript: The web-vitals library
In real life: One stopwatch for each thing to time
In JavaScript: One function per metric: onLCP(), onINP(), onCLS(), onFCP(), onTTFB()
In real life: The rules already inside
In JavaScript: When to report, and what to do with Back, prerendering and hidden tabs
In real life: The card each stopwatch hands back
In JavaScript: The metric object your callback gets

Here is all the code a page needs. It follows the README's example for sending results to a server:

Five metrics, one way to send themJavaScript
import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals"; function sendToShop(metric) {  const body = JSON.stringify({ name: metric.name, value: metric.value, id: metric.id });  navigator.sendBeacon("/collect", body);} onLCP(sendToShop);onINP(sendToShop);onCLS(sendToShop);onFCP(sendToShop);onTTFB(sendToShop);
  • Line 1 loads five functions from the library, one per metric.
  • Lines 3 to 6 make sendToShop. It gets one metric object. It turns the metric's name, value and id into text, and sends that to the shop's server.
  • navigator.sendBeacon() posts a small piece of data to a server. MDN says it's "intended to be used for sending analytics data".
  • Lines 8 to 12 hand sendToShop to each function. The library calls it back once that metric's value is ready.

The metric object's name says which metric it is, like "LCP". Its value is the number. You'll meet its other fields as the lesson needs them.

Where should the library load? Late is fine. It asks for buffered entries, which you met in The performance timeline, so it sees what happened before it loaded. The README even says it "should be deferred until after other user-impacting code has loaded."

To see when the reports really arrive, the course built a small copy of the mint tea page and put web-vitals 6.2.3 on it. Three things on the page are slow on purpose:

  • The server holds the tea photo back for 300 ms.
  • A 600 ms timer puts a "Free delivery" banner above the photo, and pushes the photo down.
  • Add to cart's click handler keeps the main thread busy for 150 ms.

Then Playwright, a tool that drives a real browser, did what Asha did. It loaded the page, waited a second and a half, clicked Add to cart, and clicked the link away. It did this in Chrome, and in Playwright's builds of Firefox and WebKit, Safari's engine. Every report went to the course's server. Here is when Chrome's five reports arrived:

Chrome's standard callbacks on the first visit: While loading: FCP 72, reported at 76.5 ms, TTFB 6.4, reported at 332.4 ms; Click at 1879.3 ms: LCP 344, reported at 2669.2 ms; Leave at 2677.7 ms: INP 184, reported at 2689.8 ms, CLS 0.0468, reported at 2689.9 ms.While loadingFCP 72at 76.5 msTTFB 6.4at 332.4 msClick at 1879.3 msLCP 344at 2669.2 msLeave at 2677.7 msINP 184at 2689.8 msCLS 0.0468at 2689.9 ms
Chrome's five reports from Asha's visit, in three groups. FCP and TTFB came while the page loaded. LCP came after her click. INP and CLS came only after she left.

Why did some reports wait so long? The next section answers that.

The main idea

The web-vitals library turns the browser's raw entries into LCP, INP, CLS, FCP and TTFB, using Chrome's rules. You call one function per metric, with a callback. The library calls it back when the value is final. For some metrics that's at once. For INP and CLS, it's when the page is hidden, usually as the visitor leaves.

The rest of the lesson answers four questions:

  1. When does each callback run?
  2. What made a number slow? That's the attribution build.
  3. Can you hear every change? That's reportAllChanges.
  4. What about Back, prerendering and background tabs?

onLCP, onINP, onCLS, onFCP, onTTFB

Each of the five functions takes a callback. The README says the library calls it "any time the metric value is available and ready to be reported." Ready means final: the value can't change any more.

The five metrics become final at different moments. A café manager filling in a card for each visit works the same way:

Real-life analogyA visit's card is finished as the customer leaves

At Leaf and Kettle, the manager keeps a card for each customer's visit. Some lines she fills in straight away, like when the first cup reached the table. That can't change. Other lines stay open, like the slowest order, or how often the table got bumped. Another order could still be slow. So she fills those in as the customer walks out of the door.

In real life: When the first cup reached the table
In JavaScript: FCP: final as soon as it happens
In real life: The slowest order so far
In JavaScript: INP: can grow while the page is open
In real life: How often the table got bumped
In JavaScript: CLS: can grow while the page is open
In real life: Filling in the open lines at the door
In JavaScript: The callback running as the page is hidden

Reported while the page loads: FCP and TTFB

FCP, first contentful paint, is the moment the first text or image was painted. Once it has happened, nothing can change it. So the library reports it as soon as the browser's paint entry arrives.

TTFB, time to first byte, is when the first byte of the page arrived. That can't change either. But the library holds its report until the page has loaded. Its source says it waits "to ensure all properties of the navigation entry are populated." The navigation entry is the page's stopwatch record from The navigation timeline. Some of its times are only filled in at the load event.

Two reports while the page loadedPop out in the code editor (opens in a new tab)JavaScript
// Chrome, on Asha's visit: each metric's value, and when its callback ran, in ms.const reports = [  { name: "FCP", value: 72, reportedAt: 76.5 },  { name: "TTFB", value: 6.4, reportedAt: 332.4 },]; for (const { name, value, reportedAt } of reports) {  console.log(`${name} was ${value} ms. Its report came ${(reportedAt - value).toFixed(1)} ms later.`);}
  • Lines 3 and 4 hold two of Chrome's reports: the metric's value, and the time its callback ran.
  • Line 8 subtracts the value from that time: the gap between the moment it measured and its report.
  • It prints FCP was 72 ms. Its report came 4.5 ms later. and TTFB was 6.4 ms. Its report came 326.0 ms later.

FCP was reported almost at once. TTFB waited about a third of a second, because the load event waited for the photo, and the server held the photo back. Why it matters: the TTFB report carries the whole navigation entry, in metric.entries. So you can send the connection and server times from lesson 5.1 along with it.

LCP: final at the first click or key press

LCP is the commentator from Paint, LCP, and layout shift APIs. She calls out the biggest element painted so far. She stops at the whistle: the visitor's first input. So LCP can't be final until the visitor does something.

The library listens for three things: a click, a keydown, or the page being hidden. The first of them ends LCP.

Even then, it doesn't report straight away. It waits for the browser's next idle moment, using requestIdleCallback(). That function asks the browser to run some code when it has nothing else to do. The library's source says it waits "to reduce potential INP impact": its own work shouldn't slow down the click that ended LCP. The wait is capped at 1 second. If the page is hidden first, it reports at once.

LCP waits for the clickPop out in the code editor (opens in a new tab)JavaScript
// Chrome, on Asha's visit, in ms.const lcp = { value: 344, reportedAt: 2669.2 };const click = 1879.3; // Playwright clicked Add to cart console.log(`The hero photo was painted at ${lcp.value} ms.`);console.log(`Its LCP report waited for the click, then ${(lcp.reportedAt - click).toFixed(1)} ms more.`);
  • Line 2 holds Chrome's LCP report. The photo was painted at 344 ms, and the callback ran at 2669.2 ms.
  • Line 3 is when Playwright clicked Add to cart.
  • Line 5 prints The hero photo was painted at 344 ms.
  • Line 6 subtracts the click from the report's time. It prints Its LCP report waited for the click, then 789.9 ms more.

Chrome found its idle moment 789.9 ms after the click. Firefox found one 177 ms after its click. WebKit doesn't have requestIdleCallback(), so the library uses a plain timer there. WebKit's report came 152 ms after the click, just after the click handler's 150 ms of work.

INP and CLS: final when the page is hidden

A slow click can come at any moment. So can a layout shift. So INP and CLS can keep growing for as long as the page is open, like the open lines on the manager's card.

The README says CLS and INP "should be reported any time the page's visibilityState changes to hidden." A page is hidden when the visitor can't see it any more. They switched tabs, minimised the window, went to another page, or closed the tab. The browser then fires a visibilitychange event, and document.visibilityState becomes "hidden".

Why hidden, and not when the page closes? Chrome's Page Lifecycle guide says the change to hidden "is also often the last state change that's reliably observable by developers". On phones, people close tabs or the whole browser, and the page gets no other event. Lesson 5.1 showed the same problem with unload.

INP and CLS wait for Asha to leavePop out in the code editor (opens in a new tab)JavaScript
// Chrome, on Asha's visit, in ms.const leave = 2677.7; // Playwright clicked the link to the next pageconst reports = [  { name: "INP", value: 184, reportedAt: 2689.8 },  { name: "CLS", value: 0.0468, reportedAt: 2689.9 },]; for (const { name, value, reportedAt } of reports) {  console.log(`${name} ${value}: reported ${(reportedAt - leave).toFixed(1)} ms after she clicked the link.`);}
  • Line 2 is when Playwright clicked the link to the next page.
  • Lines 4 and 5 hold Chrome's INP and CLS reports, with the time each callback ran.
  • Line 9 prints how long after the click each one came: INP 184: reported 12.1 ms after she clicked the link. and CLS 0.0468: reported 12.2 ms after she clicked the link.

Both came as the page was hidden: each report says the page's visibilityState was already "hidden" when it was sent.

If Asha had only switched tabs and come back, the library would have kept measuring. The README warns that the callback "might be called multiple times during the same page load." The reportAllChanges section shows how to handle that.

That's also why the shop's code uses navigator.sendBeacon(). It's like posting a sealed envelope as you walk out of the door: it still arrives after you've gone. The README picks it because it "supports sending data as the page is being unloaded." Sending performance data looks at it closely.

Firefox and WebKit sent INP too, but no CLS at all. CLS is built on the Layout Instability API, which only Chromium browsers have. The README lists onCLS() as "Chromium" only.

Here is every report from the recording. Start with Chrome: find the paint, the click, the moment the page was hidden, and the reports around them. Then try Firefox and WebKit.

Playground: every report, in order
Beaconstime, metric, value
  1. 34.3 mspage starts: visible
  2. 76.5 msFCP 72 ms navigate, visible
  3. 77 msfirst contentful paint at 72 ms
  4. 332.4 msTTFB 6.4 ms navigate, visible
  5. 1,880 msPlaywright clicks
  6. 2,669.2 msLCP 344 ms navigate, visible
  7. 2,678 msPlaywright clicks
  8. 2,689.8 msINP 184 ms navigate, hidden
  9. 2,689.9 msCLS 0.0468 navigate, hidden
  10. 2,689.9 mspage is now hidden
  11. 3,508.6 mspage is now visible
  12. 3,509 mspage restored from the back/forward cache
  13. 3,509.1 msTTFB 0 ms back-forward-cache, visible
  14. 3,524.8 msFCP 17.9 ms back-forward-cache, visible
  15. 3,525.1 msLCP 18.4 ms back-forward-cache, visible
  16. 5,045.3 msPlaywright clicks
  17. 5,810.9 msPlaywright clicks
  18. 5,818.7 msINP 152 ms back-forward-cache, hidden
  19. 5,818.8 msCLS 0 back-forward-cache, hidden
  20. 5,818.9 mspage is now hidden
Try it yourself
Chrome: load, click, leave, come back, click, leave: 10 Standard reports

This visit reported FCP, TTFB, LCP, INP, CLS. Each row is one message the page sent to the course's server. The time is the page's own clock, performance.now(). Rows in italics are the page's own events, like a click or the page being hidden.

The course recorded these with web-vitals 6.2.3 on an Apple M1 Pro, in Chrome 156, Firefox 155.0 and WebKit 26.6.

The attribution build

Asha's LCP was 344 ms and her INP 184 ms. Both are good. But suppose they weren't. What would you fix? A number on its own can't tell you.

Real-life analogyA report card that says why

A report card that says "Maths: C" tells you something went wrong, but not what. A better card adds a note from the teacher: "Lost most marks on fractions. Homework 4 came in late." Now you know where to start.

In real life: The grade: "Maths: C"
In JavaScript: The metric's value, like LCP 344 ms
In real life: The teacher's note under it
In JavaScript: The attribution object
In real life: "Lost most marks on fractions"
In JavaScript: The biggest part, like resourceLoadDuration
In real life: "Homework 4 came in late"
In JavaScript: The element or script it names, like #hero

The library comes in two builds, two ready-made versions of its code. The standard build gives you the numbers. The attribution build adds a note to every report, in an object called attribution. The README calls it "potentially-helpful debugging information that can be sent along with the metric values".

To switch, change one line: import from web-vitals/attribution instead of web-vitals. The functions and callbacks stay the same. The notes cost some bytes, though. Compressed with Brotli, the standard build was 3,037 bytes and the attribution build 4,978. The course's page used the attribution build, so every recorded report has a note.

LCP, in four parts

LCP's note splits its time into four parts, one after the other:

  • timeToFirstByte: from the start of the visit to the page's first byte.
  • resourceLoadDelay: from the first byte until the browser started loading the LCP image.
  • resourceLoadDuration: how long the image took to download.
  • elementRenderDelay: from the image arriving until it was painted.

The four parts of LCP takes each one apart later in the course. Here they are for Asha's visit:

Where LCP's time wentPop out in the code editor (opens in a new tab)JavaScript
// Chrome's LCP report on Asha's visit: 344 ms, cut into four parts (in ms).const parts = {  timeToFirstByte: 6.4,  resourceLoadDelay: 19,  resourceLoadDuration: 305.8,  elementRenderDelay: 12.8,}; const total = Object.values(parts).reduce((sum, time) => sum + time, 0);const [biggest] = Object.entries(parts).sort((a, b) => b[1] - a[1]);console.log(`The four parts add up to ${total.toFixed(1)} ms.`);console.log(`The biggest is ${biggest[0]}: ${biggest[1]} ms.`);
  • Lines 3 to 6 hold the four parts from Chrome's LCP report, in milliseconds.
  • Line 9 adds them up. Line 10 sorts them, biggest first, and keeps the first one.
  • Line 11 prints The four parts add up to 344.0 ms. That's the LCP itself.
  • Line 12 prints The biggest is resourceLoadDuration: 305.8 ms.

So 305.8 of the 344 ms went on downloading the photo. That's the server holding it back for 300 ms. The note also named the element, #hero, and its file, hero.jpg. Why it matters: you now know to speed up that one image's download. A faster first byte or a quicker paint would barely move LCP.

INP, in three parts

INP's note splits the slowest interaction into the three waits from the café counter in Event Timing and INP:

  • inputDelay: waiting for the cook to hear the bell, before the click's handlers start.
  • processingDuration: the cook making the tea, while the handlers run.
  • presentationDelay: waiting for the waiter's round, until the next frame is painted.
Where INP's time wentPop out in the code editor (opens in a new tab)JavaScript
// Chrome's INP report on Asha's visit: 184 ms, cut into three parts (in ms).const parts = {  inputDelay: 0.3,  processingDuration: 151.1,  presentationDelay: 32.6,}; const total = Object.values(parts).reduce((sum, time) => sum + time, 0);const [biggest] = Object.entries(parts).sort((a, b) => b[1] - a[1]);console.log(`The three parts add up to ${total.toFixed(1)} ms.`);console.log(`The biggest is ${biggest[0]}: ${biggest[1]} ms.`);
  • Lines 3 to 5 hold the three parts from Chrome's INP report.
  • Lines 8 and 9 add them up and find the biggest, as before.
  • It prints The three parts add up to 184.0 ms. and The biggest is processingDuration: 151.1 ms.

Most of the time went on the click handler itself: the 150 ms of work behind Add to cart. The note named the element Asha clicked, #cart.

In Chrome, the note went one step further and named the script. Chrome has the Long Animation Frames API from Long Tasks and Long Animation Frames. So the note lists the long frames that overlapped the click. Its longestScript names the longest script in them: BUTTON#cart.onclick, during the processing part. Firefox and WebKit don't have long animation frames, so their notes named the button, but no script.

The picture puts both metrics side by side, to scale:

Chrome's attribution: LCP 344 ms = timeToFirstByte 6.4 ms + resourceLoadDelay 19 ms + resourceLoadDuration 305.8 ms + elementRenderDelay 12.8 ms; INP 184 ms = inputDelay 0.3 ms + processingDuration 151.1 ms + presentationDelay 32.6 ms.LCPresourceLoadDuration344 ms = 6.4 + 19 + 305.8 + 12.8INPprocessingDuration184 ms = 0.3 + 151.1 + 32.6
LCP and INP from Asha's visit, each cut into its parts, to scale. The shaded part is the biggest one, so start there.

CLS: the biggest shift

CLS's note points at the single biggest layout shift. It names the first element that moved, #hero, and when: at 637.9 ms. That's just after the banner's 600 ms timer put it in. Lesson 11.3 showed that a shift's record names what moved, not what pushed it. So look just above the photo, and you find the banner.

reportAllChanges

The standard callback waits for the final value. While you're debugging, you may want to watch a value change as the page loads. For that, pass an options object as the second argument to any of the five functions: onLCP(sendToShop, { reportAllChanges: true }).

Real-life analogyPhoning head office with every change

Head office can ask a manager to phone once, as a customer leaves: "Slowest order: 5 minutes." Or it can ask for a call each time the number changes: "3 minutes." Later: "Now 5 minutes, 2 more than before." Either way, the last call gives the same number.

In real life: One call, as the customer leaves
In JavaScript: The standard callback
In real life: A call each time the number changes
In JavaScript: A callback with reportAllChanges: true
In real life: "Now 5 minutes: 2 more than before"
In JavaScript: The report's delta
In real life: The visit's card number, given on every call
In JavaScript: The report's id

The course's page set up both kinds of callback for every metric. Here is what LCP's two callbacks got on Asha's visit:

One LCP, heard once or twicePop out in the code editor (opens in a new tab)JavaScript
// Chrome, on Asha's visit: the LCP values each kind of callback got, in ms.const standard = [344];const everyChange = [72, 344]; console.log(`The standard callback got: ${standard.join(", ")}`);console.log(`The reportAllChanges callback got: ${everyChange.join(", ")}`);
  • Line 2 holds what the standard callback got: one value.
  • Line 3 holds what the reportAllChanges callback got: two values.
  • Lines 5 and 6 print The standard callback got: 344 and The reportAllChanges callback got: 72, 344.

The first LCP candidate was the heading, painted at 72 ms. The commentator called it out, and the callback ran. Then the photo was painted at 344 ms, and it ran again. Its last value, 344, is the one the standard callback got.

CLS went the same way: 0 at first, then 0.0468 once the banner pushed the photo down. FCP, TTFB and INP got one report each, because their values never changed after the first. That's the README's rule: "reportAllChanges only reports when the metric changes, not for each input to the metric." A new shift that doesn't raise CLS isn't reported. Neither is a click that doesn't raise INP.

Adding up changes with delta and id

When one metric can arrive several times, your server has to put the reports together. Two fields help:

  • delta is how much the value changed since this callback's last report. The README: "The first time the callback function is called, its value and delta properties will be the same."
  • id stays the same on every report one callback sends about one metric, on one visit.
The deltas add up to the valuePop out in the code editor (opens in a new tab)JavaScript
// The two LCP reports the reportAllChanges callback got on Asha's visit, in ms.const reports = [  { value: 72, delta: 72 },  { value: 344, delta: 272 },]; let sum = 0;for (const { value, delta } of reports) {  sum += delta;  console.log(`value ${value}, delta ${delta}. The deltas so far add up to ${sum}.`);}
  • Lines 3 and 4 hold the two LCP reports, each with its value and its delta. Both carried the same id.
  • Line 7 starts a running total at 0. Line 9 adds each delta to it.
  • Line 10 prints value 72, delta 72. The deltas so far add up to 72. and value 344, delta 272. The deltas so far add up to 344.

Adding up the deltas with the same id gives the latest value. Why it matters: some analytics tools can't replace a number they already have. They can only add to it. The README says to send those tools the delta, and add up the deltas for each id. Tools that can replace a value just need the value and the id.

This matters even without reportAllChanges, because INP and CLS can be reported again each time the page is hidden.

Should you turn it on for real visitors? The README says "in general using reportAllChanges is not needed (or recommended) for measuring these metrics in production." The final values are the same either way: in the recording, every metric's last value matched in all three browsers. So use it while you debug.

Back/forward cache restores, prerendering, and hidden tabs

Asha's first visit was a plain load. Real visits aren't always. The course recorded three more kinds in Chrome:

  • Back: after Asha left, Playwright pressed Back. Chrome showed the page again from its back/forward cache. Then she clicked Add to cart, and left again.
  • Prerendered: another page asked Chrome to load the shop in advance, hidden, and then opened it.
  • Background tab: a Meta-click (Command-click on a Mac) opened the shop in a new tab, behind the current one. Three seconds later, the tab was closed.

Here is what the library did with each one. The rest of this section takes them in turn:

Three special visits in Chrome. Restored from the back/forward cache: the library sent a new FCP of 17.9 ms, counted from the restore. Prerendered, then opened at 2462.7 ms: it sent FCP 41.3 ms, counted from the opening. Opened in a background tab, hidden at 0 ms: it painted at 28 ms, and the library sent no FCP.Back button: restored from the back/forward cachepageshow, persistedFCP 17.9 msnew id, from the restorePrerendered, then openedopened at 2462.7 msFCP 41.3 msfrom the openingOpened in a background tabhidden at 0 msFCP 28 mspainted, not sent
Three visits that aren't a plain load. A restore and a prerender each get their own FCP, counted from when the visitor saw the page. The background tab painted, but sent no FCP.

Coming back with the Back button

The back/forward cache, or bfcache, keeps a page in memory after you leave it. web.dev calls it "a snapshot of the entire page in memory, including the JavaScript heap". The heap is the memory where the page's JavaScript keeps its objects. Press Back, and the browser shows the page again at once, without loading it.

So when Asha pressed Back, the page didn't load again, and its scripts didn't start over. The library had to notice the restore by itself. It listens for the pageshow event. web.dev explains: "The pageshow event has a persisted property, which is true if the page was restored from bfcache and false otherwise."

Real-life analogyComing back for a forgotten scarf

Asha pays and walks out. Then she remembers her scarf and comes straight back in. Her table is just as she left it, cup and all. The manager doesn't add this to Asha's first card. She starts a new one, for a visit that began when Asha came back through the door.

In real life: Asha walking out of the café
In JavaScript: The page being hidden: the first visit's INP and CLS are sent
In real life: Her table, still just as she left it
In JavaScript: The page kept whole in the back/forward cache
In real life: Walking back in through the door
In JavaScript: The pageshow event, with persisted set to true
In real life: A new card, timed from the door
In JavaScript: New metrics with new ids, counted from the restore

The library does the same. The README says that after a restore, "a new metric object is created with a new id (since back/forward cache restores are considered separate page visits)." Each report's navigationType says which kind of visit it belongs to:

A restore gets its own FCPPop out in the code editor (opens in a new tab)JavaScript
// Two FCP reports from Chrome: Asha's first visit, and the same page after she pressed Back. In ms.const reports = [  { navigationType: "navigate", value: 72 },  { navigationType: "back-forward-cache", value: 17.9 },]; for (const { navigationType, value } of reports) {  console.log(`${navigationType}: FCP ${value} ms`);}
  • Lines 3 and 4 hold two FCP reports Chrome sent: one from the first visit, one after the restore.
  • Line 8 prints each one: navigate: FCP 72 ms and back-forward-cache: FCP 17.9 ms.

The restore sent five reports of its own, each with a new id. TTFB was 0, because nothing came from the network. FCP was 17.9 ms and LCP 18.4 ms. There's no load to watch after a restore, so the library times each one from the restore to the next painted frame. INP was 152 ms, from Asha's second click on Add to cart. CLS was 0: the banner had gone in during the first visit, so nothing moved this time.

Why it matters: Chrome's own field data counts restores this way. web.dev says tools like the Chrome User Experience Report "treat bfcache restores as separate page visits in their dataset." So your numbers line up with Chrome's, and a fast restore isn't lost inside the first visit.

Opened after a prerender

Prerendering loads a page before the visitor asks for it. Chrome's docs say a prerender "behaves as if the page has been opened in an invisible background tab". When the visitor goes to the page, Chrome swaps it in. A site lists the pages it wants prerendered in speculation rules, a small block of JSON in a script tag.

While the page is prerendering, document.prerendering is true. When the visitor opens it, the page is activated. The prerenderingchange event fires, and the navigation entry's activationStart records when.

The visitor only starts waiting when they open the page. It's like a café that lays your table before you arrive: your wait starts at the door. Chrome's docs say Core Web Vitals "are measured based on activation time." So the library waits for activation, then subtracts activationStart from TTFB, FCP and LCP:

A prerender counts from the openingPop out in the code editor (opens in a new tab)JavaScript
// Chrome, the prerendered visit. Times in ms from when Chrome started loading the page, hidden.const activationStart = 2462.7; // when the visitor opened itconst paint = 2504; // the first contentful paint console.log(`FCP, counted from when the page was opened: ${(paint - activationStart).toFixed(1)} ms`);
  • Line 2 is when the visitor opened the page, counted from when Chrome started loading it.
  • Line 3 is when the page painted its first text or image, on the same clock.
  • Line 5 subtracts one from the other. It prints FCP, counted from when the page was opened: 41.3 ms.

The prerendered page reported FCP and LCP of 41.3 ms, both with the navigationType "prerender". TTFB was 0: the first byte had arrived long before the page was opened. Why it matters: a visitor who opens a prerendered page barely waits, and your data shows exactly that.

Loaded in a background tab

The page loaded, and painted its first text at 28 ms. But it started out hidden, behind the tab Asha was reading.

How did the library know? Chrome writes a visibility-state entry in the performance timeline each time the page's visibility changes. In Chrome, each visit's first entry gave the state the page started in, at 0 ms. This tab's first entry said "hidden". The library takes the first hidden entry as the moment the page was first hidden. Then it only reports a paint that came before that moment:

Hidden before the paintPop out in the code editor (opens in a new tab)JavaScript
// Chrome, the background tab. Times in ms from the start of the visit.const firstHidden = 0; // the first "hidden" visibility-state entryconst paint = 28; // the first contentful paint if (paint < firstHidden) console.log("The page was visible when it painted: report FCP.");else console.log("The page was hidden before it painted: no FCP.");
  • Line 2 is when the page was first hidden: at 0, from the very start.
  • Line 3 is when it painted.
  • Lines 5 and 6 follow the library's rule: report the paint only if it came before the page was first hidden. It prints The page was hidden before it painted: no FCP.

So there was no FCP, and no LCP. There was no CLS either: the library only reports CLS for a visit that reported FCP. Its source says that's "to match the current behavior of CrUX". The tab sent TTFB, 2.5 ms, and nothing else.

web.dev says Google's tools leave these pages out of LCP, because a page loaded in the background "does not reflect the user-perceived load time." It's like a customer the manager didn't see come in: she can't say how long they waited, so she leaves those lines blank.

Firefox and Safari don't have visibility-state entries. There, the library falls back to a simpler check. Its source says: "If the document is hidden when this code runs, assume it was always hidden and the page was loaded in the background".

Here are the four kinds of visit side by side. Read each row across: the kind of visit, the navigationType its reports carried, and what the library did.

Four kinds of visit, and what the library did with each
Kind of visitIts navigationTypeWhat the library did
A plain loadnavigateIt counted every metric from the start of the visit.
Restored from the back/forward cacheback-forward-cacheIt started new metrics, with new ids, counted from the restore.
Prerendered, then openedprerenderIt waited for the page to open, and counted from activationStart.
Loaded in a background tabnavigateIt sent TTFB, but no FCP, LCP or CLS.

Which metric's attribution?

Each item below is a field from the attribution notes on Asha's visit in Chrome. Which metric's note did it come from? Sort each one into LCP, INP or CLS.

Which metric's attribution?
  • url: hero.jpg
  • resourceLoadDelay: 19 ms
  • elementRenderDelay: 12.8 ms
  • inputDelay: 0.3 ms
  • longestScript: BUTTON#cart.onclick
  • presentationDelay: 32.6 ms
  • largestShiftTarget: #hero
  • largestShiftTime: 637.9 ms
Try it yourself
0 of 8 correct

Sort each field into the metric whose note it came from.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "Each callback runs once, as soon as the page loads." No. FCP came at once, but LCP waited for the click. INP and CLS waited until the page was hidden, and they can come more than once.
  • "reportAllChanges gives a different final value." No. It only changes how often you hear. LCP ended at 344 and CLS at 0.0468 either way.
  • "After Back, the old metrics carry on." No. A restore counts as a new visit, with new ids and the navigationType "back-forward-cache".
  • "A prerendered page counts from when it started loading." No. It counts from activationStart, when the visitor opened it.
  • "Every browser reports CLS." No. Firefox and WebKit sent none: onCLS() is Chromium only.
  • "A page that painted always reports FCP." No. The background tab painted at 28 ms and sent no FCP, because it started hidden.
  • "The library must load first, or it misses the early entries." No. It reads buffered entries, so it can load late. The README says to defer it.

Practice exercises

Answer first, then use the hints.

Exercise 1 · Warm-upFirefox's LCP

Firefox's LCP report had different parts. What does the first line print now?

Starter codePop out in the code editor (opens in a new tab)JavaScript
// Firefox's LCP report on Asha's visit: 420 ms, cut into four parts (in ms).
const parts = {
  timeToFirstByte: 5,
  resourceLoadDelay: 92,
  resourceLoadDuration: 303,
  elementRenderDelay: 20,
};

const total = Object.values(parts).reduce((sum, time) => sum + time, 0);
const [biggest] = Object.entries(parts).sort((a, b) => b[1] - a[1]);
console.log(`The four parts add up to ${total.toFixed(1)} ms.`);
console.log(`The biggest is ${biggest[0]}: ${biggest[1]} ms.`);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upThe INP after Back

    These are the parts of the INP Chrome sent after Asha pressed Back. What does the first line print?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    // Chrome's INP report after Asha pressed Back: 152 ms, cut into three parts (in ms).
    const parts = {
      inputDelay: 0.1,
      processingDuration: 150.6,
      presentationDelay: 1.3,
    };
    
    const total = Object.values(parts).reduce((sum, time) => sum + time, 0);
    const [biggest] = Object.entries(parts).sort((a, b) => b[1] - a[1]);
    console.log(`The three parts add up to ${total.toFixed(1)} ms.`);
    console.log(`The biggest is ${biggest[0]}: ${biggest[1]} ms.`);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe background tab's first look

      In the playground, the background tab's first row is the page's own script starting. Was the page visible or hidden at that moment?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeNo click, no whistle

        Nobody clicked on the prerendered visit. In the playground, when its standard LCP report was sent, was the page visible or hidden?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeA rating

          The README gives INPThresholds as [200, 500]. Which rating does an INP of 250 ms get?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeINP's threshold

            Event Timing only reports events of 104 ms or more, unless you set durationThreshold. What durationThreshold does onINP() use by default, once the library is running?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask yourself three things: when the callback runs, what the note says, and what kind of visit it was.

              web-vitals library quiz · 8 questionsScore: first tries count
              1. Question 1 of 8What does the CLS line of the leaving example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Chrome, on Asha's visit, in ms.
                const leave = 2677.7; // Playwright clicked the link to the next page
                const reports = [
                  { name: "INP", value: 184, reportedAt: 2689.8 },
                  { name: "CLS", value: 0.0468, reportedAt: 2689.9 },
                ];
                
                for (const { name, value, reportedAt } of reports) {
                  console.log(`${name} ${value}: reported ${(reportedAt - leave).toFixed(1)} ms after she clicked the link.`);
                }

                Choose an answer to see the explanation.

              2. Question 2 of 8What does the LCP parts example print second?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Chrome's LCP report on Asha's visit: 344 ms, cut into four parts (in ms).
                const parts = {
                  timeToFirstByte: 6.4,
                  resourceLoadDelay: 19,
                  resourceLoadDuration: 305.8,
                  elementRenderDelay: 12.8,
                };
                
                const total = Object.values(parts).reduce((sum, time) => sum + time, 0);
                const [biggest] = Object.entries(parts).sort((a, b) => b[1] - a[1]);
                console.log(`The four parts add up to ${total.toFixed(1)} ms.`);
                console.log(`The biggest is ${biggest[0]}: ${biggest[1]} ms.`);

                Choose an answer to see the explanation.

              3. Question 3 of 8What does the second line of the reportAllChanges example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                // Chrome, on Asha's visit: the LCP values each kind of callback got, in ms.
                const standard = [344];
                const everyChange = [72, 344];
                
                console.log(`The standard callback got: ${standard.join(", ")}`);
                console.log(`The reportAllChanges callback got: ${everyChange.join(", ")}`);

                Choose an answer to see the explanation.

              4. Question 4 of 8Why do INP and CLS only arrive when the page is hidden?

                Choose an answer to see the explanation.

              5. Question 5 of 8What happens to your metrics when a page comes back from the back/forward cache?

                Choose an answer to see the explanation.

              6. Question 6 of 8A prerendered page painted long after Chrome started loading it, yet its FCP was small. Why?

                Choose an answer to see the explanation.

              7. Question 7 of 8A tab opened in the background painted, but sent no FCP. Why?

                Choose an answer to see the explanation.

              8. Question 8 of 8Why did Firefox and WebKit send no CLS?

                Choose an answer to see the explanation.

              Key takeaways

              • web-vitals measures LCP, INP, CLS, FCP and TTFB on real visitors, with Chrome's rules. You call one function per metric, with a callback.
              • Each callback runs when its value is final. FCP comes at once, TTFB after load, LCP after the first click or key press, and INP and CLS when the page is hidden.
              • The attribution build adds a note to every report. LCP and INP come split into parts that add up, with the element and, in Chrome, the script behind them.
              • reportAllChanges calls back on every change, with the same final value. Use delta and id to put reports together.
              • A restore and a prerender each count as their own visit, timed from when the visitor saw the page. A page that started hidden reports no FCP, LCP or CLS.

              Remember the one-liner.
              Each metric reports when it's final, and every visit gets its own card.

              Sources

              The facts on this page come from these docs and source files:

              Coming next: Field data from every browser. Firefox and Safari now report LCP and INP too. You'll measure every engine, and see what Chrome-only data leaves out.

              CompleteFrontend Clear concepts. Working examples.