01 — Three numbers, three dimensions

A user opens your product page on a 2021 mid-range Android. They wait. They tap “Add to cart.” The page jumps. They miss. They leave. Three things just failed — each measured by a different Core Web Vital. LCP caught the wait, INP caught the missed tap latency, and CLS caught the jump. Together they form a three-axis snapshot of how a page feels.

Google sets one threshold per axis, calibrated against the p75 of real Chrome users so the bar represents the slowest 25% of your audience rather than the median:

  • LCP (Largest Contentful Paint) ≤ 2.5s — the moment the largest viewport element finishes painting.
  • INP (Interaction to Next Paint) ≤ 200ms — the latency of the worst discrete interaction during the session.
  • CLS (Cumulative Layout Shift) ≤ 0.1 — the score of the largest 5-second burst of unexpected layout movement.

The lab on the right shows live gauges. All three start in the yellow “needs work” band because the simulated page has every classic problem — slow TTFB, a synchronous click handler, and an image without dimensions. Each step from here on isolates one metric, demonstrates it in motion, and watches a gauge sweep into the green.

02 — Which element is LCP?

LCP measures one specific element: the largest rendered element within the viewport at the moment the browser paints it. The browser keeps watching and re-reports LCP every time a larger candidate paints, so the value can move up until the user's first interaction. That's why one common bug — lazy-loading the hero image — sometimes “passes” LCP on developer machines: the hero loads so fast that even with loading="lazy" it still fires before user interaction. On a real phone the same image is the LCP element and you've just delayed it by 1.5s.

Only four element types ever qualify as LCP candidates: <img>, the poster frame of <video>, elements with a CSS background-image set via url(), and block-level text containers (<p>, <h1>, <li> blocks etc.). <canvas> and <svg> shapes are excluded by spec. (<image> inside SVG is technically allowed but rare in practice; the LCP element on most pages is <img>, a background image, a video poster, or a block of text.)

TS
1new PerformanceObserver((list) => {
2 for (const entry of list.getEntries()) {
3 // entry.element, entry.size, entry.startTime — the candidate updates
4 // every time a larger element paints, until the user clicks/keys.
5 console.log(entry.element, entry.size);
6 }
7}).observe({ type: "largest-contentful-paint", buffered: true });

Only the first user click, keypress, or scroll triggered by a keyboard stops LCP observation. (Programmatic scrolls and touch panning do not stop it — a common myth.) The web-vitals library exposes this as onLCP(callback) and calls back exactly once per page with the final value plus the chosen element.

Hover each block in the mock page on the right. Each one reports its type, viewport area, and whether the spec includes it as a candidate. The lab picks the LCP element by area — not by layout position — so size is the only thing that matters.

03 — Where does the LCP time come from?

A 4.0s LCP isn't one number — it's four sequential phases. Profiling work always starts with phase attribution because the cheap fixes for each phase are completely different. Toggle each fix in the lab to watch the total LCP collapse phase-by-phase.

TTFB (Time to First Byte) — server + network. Cold cache, slow database, redirect chains, distant origin. The browser cannot start anything until the first byte arrives. Edge caching (CDN, Vercel, Cloudflare Workers) shrinks this from 600ms+ to under 80ms for cacheable HTML. For dynamic HTML, streaming SSR (server-side rendered chunks flushed as they're ready) buys you a fast first byte even when the full document takes longer.

Resource load delay — discovery. The gap between HTML arrival and the moment the LCP resource starts downloading. If the LCP image is referenced inside CSS that's referenced inside a <link> tag, the browser must download HTML, then parse it, then download CSS, then parse it, then discover the image URL. fetchpriority="high" on the LCP <img> plus referencing the image directly in HTML (not via CSS) eliminates this. (<link rel="preload"> was the older fix but fetchpriority is the canonical Chrome 102+ approach because the browser can use it during HTML parsing — no extra round-trip.)

Resource load duration — bytes on the wire. AVIF compresses photographic content ~50% smaller than WebP and ~73% smaller than JPEG. Pair that with srcset and a sizes hint so phones download phone-sized images and laptops download laptop-sized ones. A 245KB JPEG hero becomes a 60KB AVIF on phones.

Element render delay — paint blockers. The browser has the bytes but can't paint yet because render-blocking CSS (full stylesheet) or JavaScript is still parsing. Inlining critical CSS, deferring non-critical JS, and font-display: swap (so text paints before the web font arrives) all attack this phase.

04 — INP: feel the 200ms

INP is the metric you can physically feel. Click the “Add to cart” button in the lab. Before the fix, the handler runs a real 200ms blocking task on the main thread — the same while (performance.now() - start < 200) pattern that real bad code produces. You will feel the lag: the button does not depress immediately, the label does not change, your subsequent clicks queue up behind the blocked thread. Flip the toggle and the same click returns in roughly 16ms.

INP replaced FID in March 2024. FID was deeply misleading: it captured only the first input delay, so a page that loaded fast but spent the next minute janking on every interaction would still pass FID with flying colors. INP fixes this by tracking every discrete interaction (clicks, taps, key presses — not scrolls, not pointermoves) and reporting the worst latency from the session. For pages with more than 50 interactions, INP reports the 98th-percentile interaction; for everything else, it reports the worst single one. Either way, FID's “first impression only” blind spot is closed.

Every interaction has three sub-phases that sum to its latency:

TS
1// web-vitals v4
2import { onINP } from "web-vitals";
3
4onINP((metric) => {
5 // metric.value — total INP in ms
6 // metric.attribution.eventType, eventTarget, longAnimationFrameEntries
7 // — the LoAF entries that overlapped this interaction (Chrome 123+).
8 navigator.sendBeacon("/rum", JSON.stringify(metric));
9});

Input delay — time the event sat in the queue because the main thread was busy. This is usually the dominant phase. Processing — your event handler running. Presentation delay — style/layout/paint/composite after the handler returned. Forced reflows (reading offsetHeight after writing style.height) blow this up.

LoAF (Long Animation Frames API) is the modern way to debug INP. Where Long Tasks API reported “something ran for 78ms,” LoAF reports the full animation frame including blocking scripts, style/layout work, and the source URL of the offending script. It is the only tool that reliably catches the third-party script blocking your interactions:

TS
1new PerformanceObserver((list) => {
2 for (const entry of list.getEntries()) {
3 // entry.scripts[] — each blocking script with source URL,
4 // forcedStyleAndLayoutDuration, duration, invokerType
5 console.log(entry.scripts);
6 }
7}).observe({ type: "long-animation-frame", buffered: true });

05 — Fixing INP

Three modern patterns, in order of how aggressively they attack input delay:

scheduler.yield() (Chrome 129+). Inside a long task, await scheduler.yield() to hand the main thread back to the browser. Pending input events run, then your continuation resumes — without going to the back of the task queue the way setTimeout(0) does. This is the only API that gives you “yield to input but keep my priority above background work.”

TS
1async function hydrateLargeList(items: Item[]) {
2 for (let i = 0; i < items.length; i++) {
3 hydrate(items[i]);
4 if (i % 16 === 0) {
5 await scheduler.yield(); // browser processes pending input here
6 }
7 }
8}

If scheduler is undefined (Safari, older Chrome) the polyfill is await new Promise(r => setTimeout(r, 0)) — slower because setTimeout(0) always re-queues at lowest priority, but functionally correct. See the perf-js lesson for the full yield strategy decision tree.

requestAnimationFrame for DOM writes. When your handler updates the DOM, wrap the write in requestAnimationFrame so the work happens right before the next paint. The handler returns immediately (input delay shrinks), and the layout/paint cost moves into the natural 16ms frame budget.

content-visibility: auto for off-screen sections. Wrapping each below-the-fold section in content-visibility: auto tells the browser “skip layout and paint for this until it scrolls near the viewport.” Style recalculation drops dramatically — the worst case for a 200-item list is no longer “lay out 200 items,” it's “lay out 10.”

The lab's tabs walk through all three: each one shows the same 200ms baseline and the resulting INP after the fix.

06 — CLS is the worst session window, not the sum

The most common bug in CLS implementations is treating it like a running total. CLS is not the sum of all shifts. A page with one bad shift in second 1 (score 0.18) followed by 30 tiny shifts spread across the next two minutes (cumulative score 0.4) reports CLS = 0.18, not 0.58. The spec defines a session window:

  • A window starts at the first shift.
  • A new shift extends the current window if it arrives within 1 second of the previous shift and within 5 seconds of the window's start.
  • Otherwise, a new window begins.
  • CLS is the maximum window's score across the page lifetime.
TS
1// web-vitals handles the session-window logic for you.
2import { onCLS } from "web-vitals";
3
4onCLS((metric) => {
5 // metric.value — largest-window CLS so far
6 // metric.entries — the layout-shift entries that built it
7 // metric.attribution.largestShiftSource — top-most affected element
8 navigator.sendBeacon("/rum", JSON.stringify(metric));
9});

Each shift has its own score: impact_fraction × distance_fraction. Impact fraction is the percentage of the viewport covered by the union of “before” and “after” rectangles. Distance fraction is how far the element moved as a percentage of the viewport's largest dimension. A full-width hero that drops 250px on a 1000px-tall viewport scores ~0.30 × 0.25 = 0.075 — close to blowing the 0.1 budget by itself.

The lab on the right replays a four-shift sequence:

  1. t = 1.2s — hero image without width/height pushes content down (0.105)
  2. t = 1.6s — sticky ad container inflates (0.062)
  3. t = 1.95s — recommendation widget mounts above content (0.026)
  4. t = 4.2s — font swap reflows text (0.020)

Naïve “sum” would be 0.213. Correct CLS is the largest session window — shifts 13 cluster (gap < 1s, total duration 0.75s) into a window scoring 0.193. Shift 4 starts a new window (gap 2.25s) scoring only 0.020. CLS = 0.193, not 0.213.

The fixes are unglamorous and effective: set explicit width/height (or aspect-ratio) on every image and embed; reserve min-height on containers that load dynamic content; use size-adjust and ascent-override on @font-face to match fallback metrics to the web font so swap doesn't reflow; and never inject content above existing content (insert below, animate in).

07 — Field beats lab, always

Your Lighthouse run shows LCP 1.2s on the homepage. Production CrUX p75 shows LCP 2.8s. The gap is real and field is the truth — for one reason: field is always slower than lab. Lab is one optimistic device (Lighthouse simulates Moto G Power on slow 4G, but your CI machine is probably even faster than that). Field is the slowest 25% of real users on 28 days of traffic, including the user with three tabs open, a flaky tower, and a phone from 2019.

The lab's slider lets you see the gap shrink — never invert — as the p75 user's hardware improves. The closer your real users get to flagship Wi-Fi, the smaller the gap. But the field gauge can never beat the lab gauge: a lab run on optimistic hardware will always show better metrics than the slowest quartile of real-world users, regardless of how good your user base gets.

TS
1// RUM beacon — send field metrics from production
2import { onLCP, onINP, onCLS } from "web-vitals";
3
4const send = (metric) => {
5 // sendBeacon survives page unload; fetch() does not.
6 navigator.sendBeacon("/rum", JSON.stringify({
7 name: metric.name,
8 value: metric.value,
9 rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
10 delta: metric.delta,
11 id: metric.id,
12 navigationType: metric.navigationType, // includes 'back-forward-cache'
13 entries: metric.entries,
14 }));
15};
16
17onLCP(send);
18onINP(send);
19onCLS(send);

Three modern wrinkles for RUM accuracy:

bfcache restorations. When the user hits Back and Chrome restores the page from bfcache, that restoration is a new navigation and gets its own LCP/INP/CLS. bfcache-restored pages are wildly faster (LCP0) and pull your p75 numbers down — desirable, but only if you actually qualify for bfcache (no unload handler, no Cache-Control: no-store on the document, etc.). metric.navigationType === 'back-forward-cache' lets you slice this dimension in your dashboard.

navigator.sendBeacon over fetch. Use beacon for the unload paths (visibilitychange to hidden, pagehide). fetch requests are cancelled when the browser tears down the page; sendBeacon is queued by the user agent and delivered after unload. web-vitals already uses the right transport — only roll your own if you must.

Speed Insights and CrUX disagree. Speed Insights is your private RUM; CrUX is Chrome's aggregated public dataset. CrUX excludes browsers with “make Chrome more secure” off, excludes incognito sessions, and aggregates on a 28-day window. You can pass Speed Insights and fail CrUX for two months because of the lag. The fix: monitor both and treat CrUX as the slow but authoritative source of truth.

The 75th-percentile rule is what makes Core Web Vitals strict by design. Reporting against p50 would hide your slow users. Reporting against p99 would punish you for legitimately broken devices. p75 is the threshold “75% of your real users get the good experience” — pass it, and the metric is green; fail it, and the metric is yellow or red regardless of how good your lab numbers look.

TxLcLoInIoClFl
step 01

Three numbers, three dimensions

LCP2.7sNeeds workGood ≤ 2.5sLargest Contentful Paint
INP240msNeeds workGood ≤ 200msInteraction to Next Paint
CLS0.19Needs workGood ≤ 0.1Cumulative Layout Shift

Each gauge is a live PerformanceObserver output. Flip toggles in the next steps to watch each value sweep from red to green.