Three Metrics, Three Numbers: 2.5s, 0.1, and 200ms at the 75th Percentile
"LCP 2.1 s, CLS 0.08, INP 180 ms" — you should be able to hear those three numbers and know instantly which are fine. Core Web Vitals are three field metrics with hard thresholds:
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP — Largest Contentful Paint | when the largest above-the-fold element paints | ≤ 2.5 s | 2.5–4 s | > 4 s |
| CLS — Cumulative Layout Shift | unexpected movement (unitless) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
| INP — Interaction to Next Paint | latency of clicks, taps, keys across the whole visit | ≤ 200 ms | 200–500 ms | > 500 ms |
INP is the newest: it replaced First Input Delay in March 2024. FID measured only the input delay of the first interaction — a load-time impression. INP observes every click, tap, and key press and reports the interaction's full latency to the next painted frame, so it grades the page's entire life. Hover and scroll are not observed, which is why a scroll-only session reports no INP at all.
Where a number comes from decides what you are allowed to optimize against:
- Lab (Lighthouse: one synthetic run, throttled CPU/network) is diagnostic — it hands you render-blocking resources and long-task bars, but it often reports no INP, because nobody clicked anything.
- Field (CrUX's 28-day dataset, or your own RUM) is the verdict: the 75th percentile of page loads, segmented mobile vs desktop. A page is "good" when p75 clears the threshold, not when your laptop feels fast.
The three are independent by design: a page can paint in 1.5 s and still score poor INP because a filter handler blocks the thread; another can be slow to load and still post perfect CLS. A green Lighthouse score is evidence about one synthetic run on one throttled profile — it says nothing about the interactions users actually perform, and nothing about the mid-range device that is a third of your traffic. metric.rating applies the same thresholds as the table to a single sample, which is what lets your pipeline bucket reports without re-deriving the bands.
Sourcing, in priority order: field first — PageSpeed Insights shows CrUX's 28 days of field data above its own lab run, and that ordering is correct. Field answers what to fix; the lab answers where to look. When field data reports no INP for a URL, check two mundane causes before panicking: scroll-and-hover-only sessions are never observed, and low-traffic URLs lack enough interaction samples. In the lab, Total Blocking Time stands in for INP — the same long tasks, minus the user.
Wiring measurement into the app is one client component:
'use client'
import { useReportWebVitals } from 'next/web-vitals'
const send = (metric: { name: string; value: number; id: string }) => {
navigator.sendBeacon('/analytics/web-vitals', JSON.stringify(metric))
}
export function WebVitals() {
useReportWebVitals(send) // callback identity must stay stable
return null
}
Import it from the root layout and keep the function outside the component: an inline arrow gets a new identity every render, re-registers, and reports duplicates of metrics already observed. The payload gives you metric.name, metric.value, metric.rating (good / needs-improvement / poor), metric.entries for attribution, and navigationType — worth segmenting on, since back-forward-cache and soft-navigation visits behave differently from a fresh load. sendBeacon matters because INP finalizes when the page is hidden or unloaded; a fetch that never lands in time silently loses the worst interactions.
Percentile math deserves one sentence of skepticism: p75 across users hides your worst 25%. Reading a green dashboard while one device class churns is the standard failure — segment by device and page type before declaring victory.
With the numbers fixed, the rest of this topic is diagnosis: which component produces the LCP element, which element moves after paint, and which interaction takes 620 ms to answer.
LCP and CLS: The Lazy Hero That Arrives Late and the Banner That Shoves It
LCP — dissect the largest element. The browser tracks the largest visible element in the initial viewport — usually a hero <img>, a text block, or an element with a background image — and records when it paints. Observation stops once the user starts interacting, so it is a load metric with four sub-parts, each with a different owner:
| Sub-part | Question it answers | Typical Next.js fix |
|---|---|---|
| TTFB | when did HTML start arriving? | cache the route, speed the origin, stream with Suspense |
| Resource load delay | when did the browser decide to fetch the image? | preload / fetchPriority="high"; never lazy-load it |
| Resource load time | how long did the bytes take? | correct sizes, closer CDN, WebP/AVIF |
| Element render delay | when did layout let it paint? | font swap blocking a text LCP; render-blocking CSS |
The canonical Next.js bug is one line: <Image src="/hero.jpg" /> with default props. Default loading="lazy" defers the fetch until the layout positions the element, so resource load delay alone can cost hundreds of milliseconds on the exact element that defines LCP. The fix from Optimizing Images with next/image transfers directly: preload (or loading="eager" + fetchPriority="high") on the single LCP candidate, accurate sizes, nothing else eager. Note the second-order effect: if the LCP element is a headline rather than an image, font-swap timing lands inside element render delay — which is why metric-tuned fallbacks in Self-Hosted Fonts with next/font move LCP, not just CLS.
CLS — the score has a formula. For each unexpected shift:
shift score = impact fraction × distance fraction
impact = share of the viewport affected by the moved elements
distance = how far the furthest element traveled, as a viewport fraction
Work an example: elements covering half the viewport (impact = 0.5) sliding down 20% of the viewport height (distance = 0.2) score 0.10 — a single shift sitting exactly on the good/poor boundary. Shifts less than a second apart group into a session window, windows cap at 5 seconds, and the page's CLS is its worst window, not a running sum — one five-second burst of chaos defines the score.
The usual culprits, matched to fixes:
- image or iframe with no reserved box →
width/height, orfillinside an aspect-ratio container; the browser reserves space before bytes arrive; - cookie banner, consent toast, or ad injected above content → render below existing content, or reserve
min-heightfor the slot; - font swap re-wrapping paragraphs →
next/font's adjusted fallback metrics; - client-only content appearing after hydration → put a skeleton of the final size in the server HTML — a skeleton that grows still shifts.
Two rules keep CLS honest: shifts within 500 ms of a user input are excluded (a dropdown pushing content down on click is not penalized — the user asked), and only unexpected movement counts, so position: fixed overlays that never participate in layout are free. Your enemy is movement the reader didn't request.
What qualifies as an LCP candidate: an <img>, a block-level text element (a headline paragraph counts as one unit), an element whose background image loads through CSS, or a video poster. The largest one in the initial viewport wins, and candidates stop updating at the first click or key press — that boundary is what makes LCP a load metric and leaves everything after it to INP's territory. It also complicates "preload the hero": when desktop shows a text headline and mobile shows an image, the candidate differs by viewport, which is exactly the case the docs warn against preloading — you are then choosing per viewport or arguing that one element is largest everywhere.
LCP is one load event and CLS is a stability score; the third vital is neither — it is latency, repeated across every interaction the user attempts.
INP Replaced FID: Diagnosing the 620ms Button Press
The report says a settings-page button has an INP of 620 ms — poor. FID would never have flagged it: the user's first interaction was a nav link, and FID only measured the delay before that first handler ran. INP watches every click, tap, and key press, and for most pages reports the slowest one (one outlier is discarded per 50 interactions, so a single hiccup doesn't define the page; at p75, the threshold back in chapter 1 is good ≤ 200 ms, poor > 500 ms).
An interaction's latency splits into three phases, each with its own fixes:
| Phase | What it contains | Fixes |
|---|---|---|
| Input delay | time before your handlers run — usually long tasks already queued (hydration burst, third-party init) | defer vendors with lazyOnload, hydrate less, break tasks |
| Processing duration | your event handlers executing | less work per handler; startTransition for non-urgent state; workers |
| Presentation delay | until the next frame paints | kill layout thrash, shrink the DOM, simplify style recalculation |
The measurement window runs from input to the next painted frame, so everything between the tap and visible feedback counts — including work other people's scripts did while your handler waited.
One number frames why this vital exists at all: Chrome usage data shows roughly 90% of a user's time on a page is spent after it loads — so a metric suite that grades only the opening seconds grades the least representative part of the visit. INP is how the suite accounts for the other 90%, which is also why a perfect LCP says nothing about whether your filters feel usable.
Where 620 ms usually comes from in a Next.js app:
DevTools → Performance: record, click the button
long-task bars > 50 ms overlapping the click?
├── in the post-hydration burst → third-party scripts (revisit next/script strategies)
├── inside your handler → parsing/formatting in the click path; move it out
└── style & layout recalculation → huge lists, deep nested grids, forced synchronous reflows
Concrete moves, each connected to something you already set up: a vendor shipping afterInteractive competes with your first interactions — move non-essentials to lazyOnload (Loading Scripts with next/script); a filter keystroke that re-sorts 10,000 rows belongs in a transition or behind useDeferredValue, so urgent input paints first and the heavy list commits after:
const [query, setQuery] = useState('')
const deferredQuery = useDeferredValue(query)
<SearchInput value={query} onChange={setQuery} />
<ExpensiveResults query={deferredQuery} /> // low-priority render, skipped in the input's frame
Pure computation — CSV parsing, date crunching, diffing — belongs off the main thread in a Web Worker, because no amount of scheduling makes a 300 ms synchronous parse fit inside a frame budget. Streaming slow data behind Suspense also protects INP indirectly: an unbroken server wait delays hydration for everything, while a streamed shell hands interactivity over early.
Diagnosis closes the loop: field data at p75 tells you the score is bad; metric.entries gives the Event Timing entry and its duration split (input delay, processing, presentation); the Performance panel attributes it to a script URL or a component. Because INP finalizes when the page is hidden or unloaded, long-lived tabs report late — segment your pipeline by navigationType and navigationId instead of assuming one value per page view.
Then re-measure against the number, not the feeling: p75 ≤ 200 ms.
This is the last link in the chain you have been building: Router Prefetching Behavior hid navigation latency, script strategies stopped stealing the thread, fonts stopped moving text, images reserved their space and painted early. The same request-level discipline now applies before rendering, in Writing Middleware.
Quiz
1. Field data at p75 shows LCP 3.1s, CLS 0.18, INP 640ms. Which reading is correct?
2. Why did INP replace First Input Delay in March 2024?
3. LCP is 4.2s and the Performance panel shows the hero image is requested about 900ms after the HTML arrives — a long resource load delay. What is the primary fix?
4. A cookie banner inserted above the article affects half the viewport (0.5) and pushes content down by 30% of viewport height (0.3). What is the shift score, and what is the structural fix?
Flashcards
Question
The Core Web Vitals thresholds — good, needs improvement, poor — and how they are graded?
Click to reveal answer
Answer
LCP: good ≤ 2.5s, needs improvement 2.5–4s, poor > 4s. CLS: good ≤ 0.1, needs improvement 0.1–0.25, poor > 0.25 (unitless). INP: good ≤ 200ms, needs improvement 200–500ms, poor > 500ms. Grades use field data at the 75th percentile of page loads (CrUX or RUM), segmented by device; lab tools are for diagnosis and often report no INP.
Question
How does INP differ from FID, and what does it observe?
Click to reveal answer
Answer
FID (retired March 2024) measured only input delay on the first interaction — a load impression. INP observes every click, tap, and key press (never hover or scroll) across the whole visit and measures input delay + processing duration + presentation delay, from input to the next painted frame. The reported value is essentially the worst interaction (one outlier ignored per 50), finalized when the page is hidden or unloaded.
Question
The four LCP sub-parts and their Next.js fixes?
Click to reveal answer
Answer
TTFB — cache the route, faster origin, stream with Suspense. Resource load delay — preload/fetchPriority="high" on the hero and never lazy-load it. Resource load time — correct sizes, closer CDN, WebP/AVIF. Element render delay — font swap blocking a text LCP (next/font fallback metrics), render-blocking CSS. Observation stops at the first user interaction, so LCP is a load metric.
Question
How is CLS calculated, and which shifts are excluded?
Click to reveal answer
Answer
shift score = impact fraction (share of viewport affected) × distance fraction (furthest movement as viewport fraction). Shifts within a second group into session windows capped at 5 seconds, and the page's CLS is its worst window — not a sum. Only unexpected movement counts, and shifts within 500ms of user input are excluded (a dropdown opening on click is free).
Question
How do you measure Core Web Vitals in a Next.js app?
Click to reveal answer
Answer
A Client Component in the root layout calls useReportWebVitals from next/web-vitals with a stable (module-level) callback — an inline arrow re-registers and duplicates reports — then ships each metric via navigator.sendBeacon (survives unload, needed because INP finalizes on hide). Read metric.name, value, rating (good/needs-improvement/poor), entries for attribution, and navigationType/navigationId for segmentation.
Revision Notes
Key Takeaways
- 1. Thresholds: LCP ≤ 2.5s good (>4s poor), CLS ≤ 0.1 (>0.25 poor), INP ≤ 200ms (>500ms poor) — graded on field p75; lab data diagnoses but often carries no INP
- 2. INP replaced FID in March 2024: all click/tap/key interactions, full latency to the next paint, three phases (input delay, processing, presentation) each with distinct fixes, worst-ish value finalized at page hide
- 3. LCP decomposes into TTFB, resource load delay, load time, and element render delay — the classic Next.js bug is a lazy-loaded hero sitting in load delay
- 4. CLS is impact × distance over session windows with the worst window reported; reserve space for images, injected UI, and skeletons, and tune font fallbacks instead of chasing individual shifts
- 5. Measure with useReportWebVitals (stable callback, sendBeacon), segment by device and navigation type, and connect each fix to work already done: image preload, font metrics, script strategies, prefetching
Interview Tips
- • "What are the Core Web Vitals?" — three numbers with units: LCP 2.5s/4s, CLS 0.1/0.25 unitless, INP 200ms/500ms; add that INP replaced FID in March 2024 and that grading is field p75
- • "LCP is bad — how do you debug it?" — identify the largest element, split into the four sub-parts, check whether it is lazy-loaded (load delay), whether sizes picks the right variant (load time), and TTFB from the origin
- • "How would you cut CLS from 0.3 to under 0.1?" — reserve every box (width/height, aspect-ratio containers, min-height for banners/ads), skeleton at final size, next/font metric fallbacks; explain the formula and worst-window scoring so you sound like you measure, not guess
- • "INP is 620ms — where do you start?" — field entries identify the interaction and phase; Performance panel shows >50ms long tasks; then phase-specific fixes: lazyOnload third parties, startTransition/useDeferredValue for heavy state, workers for pure compute, smaller DOM for presentation delay
Cheat Sheet
Auditing Core Web Vitals
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | largest above-the-fold element paints | ≤ 2.5s | 2.5–4s | > 4s |
| CLS | unexpected movement (unitless) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
| INP | click/tap/key latency across the visit | ≤ 200ms | 200–500ms | > 500ms |
Grading = field p75 (CrUX / RUM), device-segmented. Lab (Lighthouse) diagnoses; it often has no INP. FID retired March 2024.
LCP sub-parts → fixes: TTFB → cache/stream · load delay → preload / loading="eager" + fetchPriority="high" on the hero only · load time → sizes, CDN, WebP/AVIF · render delay → font swap (next/font), render-blocking CSS.
CLS: impact fraction × distance fraction — e.g. 0.5 × 0.3 = 0.15. Session windows: 1s gaps, 5s cap, worst window wins. Excluded: shifts within 500ms of user input; only unexpected movement counts. Fixes: width/height (or fill + aspect container), min-height for injected UI, skeletons at final size, metric-tuned font fallbacks.
INP phases → fixes: input delay → lazyOnload vendors, hydrate less, break long tasks (>50ms) · processing → startTransition / useDeferredValue for non-urgent work, workers for pure compute · presentation → smaller DOM, no forced reflows, simpler style recalc.
Instrument:
'use client'
import { useReportWebVitals } from 'next/web-vitals'
const send = (m) => navigator.sendBeacon('/analytics/web-vitals', JSON.stringify(m)) // module-level!
export function WebVitals() { useReportWebVitals(send); return null } // mount in root layout
metric.entries + Performance-panel long-task bars attribute the problem; triage by navigationType/device before trusting p75.