Core Web Vitals as requirements.
"The shop should feel fast" cannot fail a release, so it never stops one. Core Web Vitals give the wish three numbers, each about something a shopper notices: when the main content showed up, how long the page took to answer a tap, and how much the page jumped around. Add a percentile, a device class and a data source, and the wish becomes a line a team can be held to.
Builds on How fast is fast enough.
The idea.
A shopper never sees your bundle size or your server time. They notice three things, and each one has a number.
Picture a rider on the Cobbleway shop with a squealing rear brake and a phone that is two years old. They open a product page for resin brake pads. Three questions run through their head without words. Has the thing I came for shown up yet? When I tapped the size, did the page react? Is the button still where I am about to press it? Every complaint about a slow site is one of those three in disguise.
Core Web Vitals give each question a metric measured inside the browser. Largest Contentful Paint (LCP) is the moment the biggest piece of content in the viewport finished drawing, a stand-in for "the page has arrived". Interaction to Next Paint (INP) is how long the page took to show a visible response after a click, tap or key press, taking close to the worst one in the visit. Cumulative Layout Shift (CLS) scores how much visible content moved without the user asking it to.
Each metric comes with two thresholds that split values into good, needs improvement and poor. The thresholds are not judged on one lucky test run. A page meets the bar when at least three out of four real page loads are good, measured separately for phones and for desktops. That last part is what turns a metric into a requirement: a number, a percentile, a device class and a data source, all of which can be checked by someone other than the person who wrote the code.
The three bars, all judged at the 75th percentile
What each vital answers, and where its bands sit
| Vital | The shopper's question | What the browser records | Good | Needs improvement | Poor |
|---|---|---|---|---|---|
| LCP | Has the main thing shown up? | Render time of the largest image or text block in the viewport | ≤ 2.5 s | 2.5 s to 4 s | > 4 s |
| INP | Did the page answer me? | Time from a click, tap or key press to the next frame drawn, near the worst of the visit | ≤ 200 ms | 200 ms to 500 ms | > 500 ms |
| CLS | Did it stay still? | Largest burst of unexpected layout shift scores | ≤ 0.1 | 0.1 to 0.25 | > 0.25 |
Cobbleway on phones: each vital's worst page against its good line
Data
| Vital (worst page template) | % of threshold |
|---|---|
| LCP · product page | 136 |
| INP · category page | 140 |
| CLS · category page | 170 |
- good threshold: Phone p75 as a share of the good threshold (%) = 100
From a wish to a line that can fail
| Version | Statement | What is missing |
|---|---|---|
| Wish | The shop should load fast | No metric, no number: nobody can say it failed |
| Metric | Product page LCP under 2.5 s | Under 2.5 s for whom? One test run passes on a developer laptop |
| Percentile | Product page LCP ≤ 2.5 s at p75 | Mixing phones and desktops lets fast desktops hide slow phones |
| Device class | Product page LCP ≤ 2.5 s at p75 on phones, and on desktops | Measured where? A lab run and real users disagree |
| Requirement | Product page LCP ≤ 2.5 s at p75 of field page loads (28 days), phones and desktops each | Nothing; it can pass or fail |
How it works.
Each vital is a precise browser measurement with its own rules: which element counts, which interactions count, which shifts count. Knowing the rules is what lets you read a bad number correctly.
LCP, the largest thing that painted
While the page loads, the browser keeps a running answer to "what is the biggest piece of content on screen right now?". Each time something larger finishes painting it records a new candidate, and the last candidate before the user interacts is the LCP. Only some elements can be candidates: an img, an image inside an svg, a video (its poster or first presented frame, whichever comes first), an element whose background image comes from url(), and block-level elements that hold text.
Size means the part the user can see: whatever is off screen or clipped does not count, and neither do margins, padding or borders. Chromium also skips elements that are invisible (opacity 0), elements that cover the whole viewport (treated as background) and low-entropy placeholder images, so a blurry stand-in cannot claim the prize. Once the user taps, scrolls or presses a key, no new candidates are recorded; whatever was largest by then is the page's LCP.
How the LCP element changes while Cobbleway's product page paints
At about 1.2 s the HTML and CSS are in and the title block paints. The photo has not arrived, so the title is the largest content on screen and becomes the first candidate.
Text paints first. Store “Cobbleway”, showing the product view. Cart button “Basket” with badge 0 (its accessible name says “Cart, 0 items”, not the number alone). Product (showing) “Trailhawk resin disc pads (pair)” by “Fits 4-piston Trailhawk and Marlin calipers”: rating 4.7 (“146 reviews”); price £24.90. Gallery: image 1 of 4 (“Pads beside the caliper, top view”); thumbnails beside it. Compound (tiles, a radio group): Resin, Sintered. Buttons: “Add to basket”. Delivery: “Ships tomorrow from Leeds”. Folded sections: Fit guide, Reviews, Returns. Note on title: candidate 1: text block
- Usually the LCP element on a product page
- Text block; needs no download if the font is ready
Four parts that add up to LCP
An LCP time on its own says the page was slow, not why. Splitting it into four consecutive parts points at the culprit. Time to first byte runs from the start of the navigation until the first byte of HTML arrives. Resource load delay is the gap between that first byte and the moment the browser starts fetching the LCP resource, usually the hero image. Resource load duration is the download itself. Element render delay is the time from the download finishing until the element is actually on screen, often spent waiting for blocking scripts or styles.
When the LCP element is text in an already available font, there is nothing to fetch, so the two middle parts are zero. web.dev suggests a healthy page spends roughly 40% of its LCP on the first byte, roughly 40% on the download, and under 10% on each of the two delays. The delays are pure waiting, which is why they are the first place to look; how to remove them is the subject of the LCP breakdown topic.
Cobbleway's 3.4 s, part by part
Scenario 1 of 3: As described.
Timeline as a list
Cobbleway's 3.4 s, part by part: 5 lanes, from 0 ms to 4,000 ms.
- 0–3,400 ms · Product page load · LCP 3.4 s
- 0–700 ms · Time to first byte · 0.7 s
- 700–2,000 ms · Resource load delay · 1.3 s: waiting for the gallery script
- 2,000–2,900 ms · Resource load duration · 0.9 s photo download
- 2,500 ms · all lanes · good: 2.5 s (deadline)
- 2,900–3,400 ms · Element render delay · 0.5 s
- 3,400 ms · Element render delay · photo painted (error, violation)
Where the time goes, against the suggested shape
- TTFB 0.7 s, width 7, Time to first byte
- load delay 1.3 s, width 13, Load delay and render delay
- download 0.9 s, width 9, Resource load duration
- render 0.5 s, width 5, Load delay and render delay
- photo requested only at 2.0 s, from 7 to 20
- TTFB ~1.4 s, width 14, Time to first byte
- ~0.3, width 3, Load delay and render delay
- download ~1.4 s, width 14, Resource load duration
- ~0.3, width 3, Load delay and render delay
- Time to first byte
- Load delay and render delay
- Resource load duration
INP, from a tap to the next frame
INP watches every click, tap and key press for the whole time the page is open, not only during loading. Hovering, scrolling and zooming are not counted. For each interaction the browser measures from the moment of input until the next frame is presented on screen, because that frame is the first chance the user has to see that something happened. The page's INP is the slowest of those interactions, with one allowance: for every 50 interactions, the single highest is ignored, so one hiccup in a long session does not define it. A visit with no counted interactions has no INP at all.
Each interaction's latency is three phases in a row. Input delay is how long the event waited before its handlers could start, usually because the main thread was busy with something else. Processing duration is the time spent in the event handlers themselves. Presentation delay is what comes after the handlers: recalculating styles, layout, paint and getting the frame to the screen. The three always add up to the interaction's latency, which is why INP is reported with them: each phase has a different owner and a different fix.
The filter tap on Cobbleway's category page
The rider taps Shimano fit at t = 0. A third-party reviews script is halfway through a long task, so the tap waits in the queue: the screen does not change.
Tap. Grid “Disc brake pads”, 2 columns: 4 items. Sort: Sort: Popular. Count: 64 products. Facet Brake fit (check): Shimano fit (23), SRAM fit (31) 1. Trailhawk resin pads · £24.90 [image 1:1] badges: Best seller. 2. Ridgeline sintered pads · £29.50 [image 1:1] 3. Mudlark resin pads · £18.00 [image 1:1] 4. Fellrunner semi-metallic pads · £32.00 [image 1:1] badges: New. Footer: a Load more button (“Show 60 more”). Note on facet:fit: tap at 0 ms · nothing drawn yet
- Filter options; each tap is one interaction
- Product grid re-rendered by the handler
One tap, three phases
Scenario 1 of 3: As described.
Timeline as a list
One tap, three phases: 3 lanes, from 0 ms to 400 ms.
- 0–70 ms · Main thread · reviews task
- 0 ms · Rider · tap
- 0–70 ms · all lanes · window: input delay 70
- 70–210 ms · Main thread · filter handler
- 70–210 ms · all lanes · window: processing 140
- 200 ms · all lanes · 200 ms (deadline)
- 210–280 ms · Main thread · style, layout, paint
- 210–280 ms · all lanes · window: presentation 70
- 280 ms · Screen · next frame (error, delayed)
Nine interactions in one visit, and the one that becomes INP
Data
| Interaction, in order | ms |
|---|---|
| Open menu | 64 |
| Pick category | 120 |
| Shimano fit | 280 |
| Sort | 150 |
| Open product | 96 |
| Resin tile | 48 |
| Add to basket | 170 |
| Type in search | 40 |
| Close basket | 56 |
- good: Latency to next frame (ms) = 200
The three phases and who usually owns them
| Phase | From → to | Cobbleway tap | Usual cause |
|---|---|---|---|
| Input delay | input → first handler starts | 70 ms | Other work on the main thread: third-party tags, hydration, timer callbacks |
| Processing duration | first handler starts → last handler ends | 140 ms | The page's own handlers: state updates, re-rendering a large list |
| Presentation delay | last handler ends → next frame shown | 70 ms | Style, layout and paint of a big DOM change, or more work queued before the frame |
CLS, the worst burst of movement
A layout shift happens when an element that was already visible starts a frame in a different position than it had in the frame before. The browser scores each shift by multiplying two fractions of the viewport: how much of the screen the moving elements touched across both frames (the impact fraction) and how far the farthest of them travelled, relative to the viewport's larger side (the distance fraction). On a portrait phone, cards that cover half the viewport and drop by a tenth of its height touch 60% of it across the two frames (their old and new positions together), so the shift scores 0.6 × 0.1 = 0.06.
Shifts are then grouped into session windows: a window keeps growing while each new shift comes less than 1 second after the previous one, and it closes after 5 seconds at most. CLS is the total of the worst window over the page's whole life, not the sum of everything, so a long-lived page is not punished just for being open. Shifts the user caused are left out: any shift within 500 ms of a tap, click or key press is flagged as having recent input and does not count, and animations done with CSS transform do not move layout at all. The causes and cures of each kind of shift are the subject of the Layout stability topic.
A late delivery strip on the category page
The grid is on screen at about 0.5 s, the web font swaps in at 0.6 s, and the rider reaches for the first card.
First paint. Grid “Disc brake pads”, 2 columns: 4 items. Sort: Sort: Popular. Count: 64 products. 1. Trailhawk resin pads · £24.90 [image 1:1] badges: Best seller. 2. Ridgeline sintered pads · £29.50 [image 1:1] 3. Mudlark resin pads · £18.00 [image 1:1] 4. Fellrunner semi-metallic pads · £32.00 [image 1:1] Footer: a Load more button (“Show 60 more”). Note on item:g1: the thumb is heading here
- Content inserted above what the rider sees
- Cards that move when it arrives
One visit's shifts, grouped into windows
Scenario 1 of 2: As described.
Timeline as a list
One visit's shifts, grouped into windows: 2 lanes, from 0 s to 8 s.
- 0.6 s · Layout shifts · 0.02
- 0.6–2.9 s · Layout shifts · window: window 1: 0.16
- 1.2 s · Layout shifts · 0.11 (error)
- 1.9 s · Layout shifts · 0.03
- 4.5 s · Layout shifts · recently viewed 0.04
- 4.5–5.5 s · Layout shifts · window: window 2
- 6 s · Rider · tap Show 60 more
- 6–6.5 s · all lanes · window: input
- 6.2 s · Layout shifts · 0.25 excluded (tick, allowed)
The 75th percentile, per device class
Every page load produces one value per vital, and a busy page produces thousands a day. To turn that pile into a pass or a fail, the program takes the 75th percentile: sort the values and read off the one that three quarters of loads beat. If p75 LCP is 2.4 s, at least 75% of loads painted their main content within 2.4 s. The median would let half of all visits be slow; p95 or p99 would be dominated by a handful of terrible connections the site cannot fix. p75 sits between those, demanding that most visits are good while staying robust to outliers. The general maths of percentiles, and why you never average them, is in the backend topic on latency percentiles.
Phones and desktops are judged separately, against the same thresholds. The thresholds are the same because a person on a phone is no more patient than one at a desk; the assessment is split because mixing them hides the slow group. When most of a site's traffic is desktop, a blended p75 can pass while most phone loads are slow. In a requirement this means two lines, or one line that says "each".
Why the program reads p75
| Statistic | What it would let through | Verdict |
|---|---|---|
| Mean | One 30 s load on a dead connection drags it up; a few instant cached loads drag it down | Moves with outliers, describes no real visit |
| p50 (median) | Half of all loads may be slow and the page still passes | Too lenient for a quality bar |
| p75 | A quarter of loads may miss the threshold | The Core Web Vitals choice: most visits good, stable from day to day |
| p95 / p99 | Almost nothing, but the value is set by the worst networks and devices | Useful for diagnosis, too noisy for a pass mark |
Field data, lab data and the supporting metrics
Field data comes from real visits: Chrome's public CrUX dataset (a 28-day rolling window, split by phone, desktop and tablet) or the site's own real-user monitoring. Lab data comes from a scripted load of one URL on one simulated device and network, as Lighthouse does. The thresholds were set for field data at p75, and that is what a Core Web Vitals requirement is judged on. A standard Lighthouse page-load run cannot even produce INP, because nobody taps during it; it reports Total Blocking Time as a stand-in. Lab runs are still essential, but for a different job: they are repeatable, so they catch a regression in a pull request long before 28 days of field data notice it. How the two are wired into a release process is covered in Lab data, field data and releases.
Supporting metrics, read as diagnostics, not as the bar
| Metric | Where | Good | What it tells you |
|---|---|---|---|
| TTFB | field and lab | ≤ 0.8 s (poor > 1.8 s) | Server, CDN, redirects and connection setup. The first slice of every LCP; a rough guide, not a Core Web Vital |
| FCP | field and lab | ≤ 1.8 s (poor > 3.0 s) | When anything at all painted. A big gap between FCP and LCP points at the hero resource; a late FCP at blocking CSS or scripts |
| TBT | lab only | < 200 ms on average mobile hardware | The parts of long tasks beyond 50 ms, summed after FCP. A lab proxy for INP risk; not a substitute for it |
In practice.
Cobbleway turns its field numbers into requirement lines, and the same moves make a strong performance answer in a front-end system design interview.
Product page LCP on phones, last 28 days
Data
| LCP (s) | % of loads |
|---|---|
| 750 ms | 3 |
| 1.25 s | 8 |
| 1.75 s | 14 |
| 2.25 s | 19 |
| 2.75 s | 14 |
| 3.25 s | 21 |
| 3.75 s | 8 |
| 4.25 s | 5 |
| 4.75 s | 3 |
| 5.25 s | 5 |
- good ≤ 2.5 s: LCP (s) = 2.5
- p50: LCP (s) = 2.7
- p75: LCP (s) = 3.4
- poor > 4 s: LCP (s) = 4
Cobbleway's p75 by page and device class
| Page template | Phone LCP | Phone INP | Phone CLS | Desktop LCP | Desktop INP | Desktop CLS |
|---|---|---|---|---|---|---|
| Home | 2.2 s | 150 ms | 0.05 | 1.5 s | 70 ms | 0.03 |
| Category listing | 2.9 s ✕ | 280 ms ✕ | 0.17 ✕ | 1.8 s | 120 ms | 0.08 |
| Product page | 3.4 s ✕ | 170 ms | 0.04 | 1.9 s | 90 ms | 0.02 |
Illustrative numbers, but a common shape. About seven in ten Cobbleway visits come from phones, and every failure is on a phone; the desktop columns pass everything. A team that tests on office laptops would see a healthy site, and one blended number per page would make the phone failures look smaller and would not say which device class to fix. Splitting by template matters as much as splitting by device: each template has its own LCP element, its own interactions and its own late content, so the fix for the product page's photo does nothing for the category page's filter chips.
| No. | Area | Requirement | Target / measure |
|---|---|---|---|
| 01 | Loading | Home, category and product pages show their main content quickly on the rider's phone. | LCP ≤ 2.5 sp75phones and desktops each Field data, 28-day window, per page template; reference phone and network as set in Designing for the device you don't own. |
| 02 | Responsiveness | Every click, tap and key press shows visible feedback within a perception-safe time. | INP ≤ 200 msp75phones and desktops each Field data per template, with the slowest interaction targets listed so each one has an owner. |
| 03 | Visual stability | Nothing the rider can see moves unless they asked it to. | CLS ≤ 0.1p75phones and desktops each Field data; promotional strips and late widgets count against the page that hosts them. |
| 04 | Diagnostics (watched, not gating) | Server and first paint stay healthy enough to leave room for LCP. | TTFB ≤ 0.8 sFCP ≤ 1.8 sp75 An alert, not a release blocker; a breach explains an LCP regression. |
| 05 | Pre-merge lab check | A pull request cannot merge if it regresses the lab run on the reference profile. | LCP ≤ 2.5 sTBT < 200 msCLS ≤ 0.1 Median of several runs; the byte and request limits behind it are set in Performance budgets. |
Reporting each vital with its phases
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
function send(metric) {
const a = metric.attribution;
const row = {
name: metric.name, // 'LCP' | 'INP' | 'CLS'
value: metric.value, // ms for LCP and INP, a score for CLS
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
template: document.body.dataset.template, // e.g. 'product'
};
if (metric.name === 'LCP') Object.assign(row, {
target: a.target, ttfb: a.timeToFirstByte, loadDelay: a.resourceLoadDelay,
loadDuration: a.resourceLoadDuration, renderDelay: a.elementRenderDelay,
});
if (metric.name === 'INP') Object.assign(row, {
target: a.interactionTarget, inputDelay: a.inputDelay,
processing: a.processingDuration, presentation: a.presentationDelay,
});
if (metric.name === 'CLS') Object.assign(row, { target: a.largestShiftTarget });
navigator.sendBeacon('/rum', JSON.stringify(row));
}
onLCP(send);
onINP(send);
onCLS(send);
Saying it in an interview
Where the vitals show up in the system flows
Trade-offs.
The vitals are good proxies, not the whole truth. Choosing what to judge on, knowing where the numbers disagree and what they cannot see keeps a team honest.
- Pro:Matches how the thresholds were designed
- Pro:Real devices and networks
- Pro:INP is actually measured
- Pro:Lab check still stops regressions before merge
- Con:Field data lags by days to weeks
- Con:Needs enough traffic per template to be stable
- Con:Two systems to run
No INP; One simulated device and network; Easy to pass while real phones fail
Dominated by networks and devices the site cannot fix; Noisy from week to week; Not comparable with CrUX or other sites
- Pro:Every browser that supports the APIs
- Pro:Split by template and release
- Pro:Phase attribution for each slow load or tap
- Con:Instrumentation and a pipeline to build
- Con:Sampling and consent to handle
Eligible Chrome users only; 28-day rolling window; No attribution; Low-traffic pages may have no data
Why the lab and the field disagree
| Difference | In the lab | In the field |
|---|---|---|
| Device and network | One simulated profile, picked by the team | Every phone and connection your visitors have |
| Cache state | Usually a cold load | A mix of first visits, repeat visits and back/forward restores |
| Interactions | None; TBT stands in for INP | Real clicks and taps for the whole visit |
| Page lifetime | Stops soon after load, so late shifts are missed | Until the tab is hidden or closed |
| Content | One URL, often a logged-out view | Personalised pages, consent states, A/B variants, ads |
What the vitals do not see
| Blind spot | Why it matters | What to add |
|---|---|---|
| Soft navigations in single-page apps | A route change inside an SPA is not a new page load, so LCP describes only the first view. Chrome 151 shipped the Soft Navigations API, but how CrUX will report soft navigations is not settled | Your own timing per route, for example from the moment of the click to the new view's main content |
| Visitors outside CrUX | CrUX counts eligible Chrome users only: no Safari, Firefox or Chrome on iOS. LCP and INP have been measurable in every major engine since December 2025; CLS was still Chromium-only then | RUM across browsers, reported per browser family |
| Correctness | A fast page that shows the wrong price or fails to add to the basket passes every vital | Error rates and task success next to the vitals |
| Smoothness after the tap | INP stops at the next frame; a janky scroll or a stuttering carousel is not an interaction | Frame-rate checks for animations |
| Cost to the device | Battery, data and memory use are invisible to all three | Byte and CPU budgets, tested on the reference device |
| Every interaction's own target | INP is one number per visit; a slow but rare action can hide behind it | A per-interaction target (Response time limits) tracked by interaction target |