Core Web Vitals — LCP, INP, CLS
TL;DR
Three Google-defined metrics that gate “good” page performance: LCP (Largest Contentful Paint — when the main content visibly appears), INP (Interaction to Next Paint — how snappy interactions feel), and CLS (Cumulative Layout Shift — visual stability). They’re measured at the 75th percentile of real users, not in lab tools. Lab tools (Lighthouse) measure proxies; field tools (CrUX, web-vitals.js) measure reality. INP replaced FID in March 2024.
In depth
What are the three Core Web Vitals and their thresholds?
| Metric | What it measures | Good (p75) | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | time to render the largest in-viewport element (image/text/video block) | ≤ 2.5s | ≤ 4.0s | > 4.0s |
| INP | latency of the worst user interaction (click/tap/keypress → next paint) | ≤ 200ms | ≤ 500ms | > 500ms |
| CLS | sum of unexpected layout shifts (visual jank) | ≤ 0.1 | ≤ 0.25 | > 0.25 |
All three at “Good” = page is “Good.” Any one in “Poor” = page is “Poor.”
What is LCP and what kills it?
The render time of the largest text block or image visible in the initial viewport. Common causes of poor LCP:
- Slow server response (TTFB). Optimize backend, use a CDN, push-cache.
- Render-blocking CSS/JS. Inline critical CSS; defer non-critical.
- Large images loaded slowly. Compress, modern formats (AVIF/WebP),
<picture>+srcset,fetchpriority="high"on the LCP image. - Client-side rendering blocking text paint. SSR or pre-render the above-the-fold content.
The fix flow:
- Identify the LCP element (Chrome DevTools → Performance → LCP marker).
- Trace why it loaded late — DNS? Connection? Server? Render-blocking resource? JS?
- Optimize the slowest segment.
fetchpriority="high" on the hero image is the cheapest LCP win — tells the browser to deprioritize other resources for this one.
What is INP and what kills it?
INP measures the latency from a user input to the next paint, across the worst interaction on the page (not the average). Long INP means clicking/tapping feels laggy.
Causes:
- Long JavaScript tasks (>50ms) blocking the main thread.
- Heavy event handlers doing too much synchronous work.
- Layout thrashing — reading then writing layout properties in a loop.
- Synchronous third-party scripts (analytics, A/B testing libraries).
Fixes:
- Break up long tasks —
setTimeout,scheduler.yield(),requestIdleCallback. - Move work off the main thread with Web Workers.
useTransitionin React 18+ marks an update non-urgent so it doesn’t block typing.- Debounce expensive handlers.
What is CLS and what kills it?
Sum of all unexpected layout shifts during the page’s lifetime (specifically: the largest shift in each session window). 0 is perfect; anything > 0.1 is noticeable.
Causes:
- Images without dimensions — when the image loads, it pushes content down.
- Ads / iframes without reserved space.
- Web fonts causing FOIT/FOUT — text wraps differently when the font loads.
- Dynamic content injected above the fold (cookie banner, sticky header appearing).
Fixes:
width/height/aspect-ratioon every image — browser reserves the box.min-heighton ad/iframe containers.font-display: swap+size-adjustto match fallback font metrics.- Skeleton loaders during data fetch instead of “empty → full” pop-in.
<!-- The attributes are what reserve the box, even when CSS
resizes the image. Omit them and the page reflows on load. -->
<img src="hero.avif" width="1200" height="630" alt="" />img { max-width: 100%; height: auto; } /* keeps the ratio */
/* reserve the box before the ad loads */
.ad-slot { min-height: 250px; }
@font-face {
font-family: Inter;
font-display: swap;
size-adjust: 107%; /* match the fallback */
}size-adjust is the one people miss. font-display: swap avoids invisible
text but guarantees a reflow when the real font arrives — matching the metrics
means the swap happens without moving anything.
How do you measure Core Web Vitals in production?
Two complementary sources:
-
Field data (real users):
- CrUX (Chrome User Experience Report) — aggregated real-user data from Chrome, queryable via PageSpeed Insights API or BigQuery.
web-vitalslibrary — ships in your page, reports each metric to your analytics:import { onLCP, onINP, onCLS } from "web-vitals/attribution"; onLCP((m) => sendToAnalytics({ name: "LCP", value: m.value, attribution: m.attribution })); onINP((m) => sendToAnalytics({ name: "INP", value: m.value })); onCLS((m) => sendToAnalytics({ name: "CLS", value: m.value }));- The
/attributionvariant includes why it was slow — element selector, load source, the slow interaction’s element.
-
Lab data (controlled environment):
- Lighthouse / PageSpeed Insights — simulated load, useful for regression testing.
- WebPageTest — more realistic conditions, multiple test locations.
- Chrome DevTools → Performance / Lighthouse panels — local debugging.
Field data is what Google rewards (search ranking). Lab data is what you debug.
Why did INP replace FID?
First Input Delay (FID) measured only the first interaction’s input delay (the time from event to handler running). Two problems:
- It only measured the first — late-page interactions (scroll, click) were invisible.
- It only measured input delay (browser dispatching the event), not the full interaction latency (event → handler → re-render → paint).
INP (Interaction to Next Paint) measures the worst interaction’s full lifecycle. It’s a fairer reflection of perceived responsiveness. Replaced FID as a Core Web Vital on March 12, 2024.
What’s the relationship between TTFB, FCP, and LCP?
Sequential in the load timeline:
| Metric | Definition |
|---|---|
| TTFB (Time To First Byte) | request fires → first byte of response |
| FCP (First Contentful Paint) | first DOM text/image painted |
| LCP (Largest Contentful Paint) | largest text/image fully painted |
Improving TTFB benefits everything downstream. FCP is a useful signal but not a Core Web Vital itself.
How do you debug a poor LCP?
- Open DevTools → Performance, record a page load.
- Look for the LCP marker — Chrome highlights the LCP element in the screenshot.
- Check the network timeline: when did the LCP resource start? Why so late?
- If it’s an image discovered late by the preload scanner: add
<link rel="preload" as="image">orfetchpriority="high". - If the response started late (high TTFB): backend / CDN issue.
- If it’s a text element waiting for the web font: optimize font loading (see Image and Font Optimization).
- If it’s an image discovered late by the preload scanner: add
- Look for render-blocking resources — CSS/JS in
<head>that block paint. Inline critical CSS, defer non-critical, async/defer scripts. - Confirm with field data — local fixes don’t always reflect what real users see.
A site has a great LCP but a terrible INP. Where do you look?
Long tasks. Open Performance → record a click → find the long task blocking the event handler or the next paint.
- Third-party scripts (analytics, ads, chat widgets) are common culprits — they hijack the main thread.
- Large hydration in SSR apps — the page renders but isn’t interactive because hydration is busy.
- React’s render after
setStateif the work is heavy —useTransition/useDeferredValueto defer non-urgent work.
The attribution variant of web-vitals tells you which element was clicked and what handler ran — start there.
What’s a realistic performance budget you’d set?
(See README table.) Per-route, per-metric:
- LCP < 2.5s p75 across all routes (Good).
- INP < 200ms p75.
- CLS < 0.1 p75.
- Per-route JS bundle ceiling (e.g., < 200 KB transferred).
- CI gate: fail the build if Lighthouse CI or bundle-size check exceeds the budget.
A budget without enforcement drifts the moment people are busy. The senior answer is always: budget + measurement + CI gate.
Gotchas / edge cases
- CLS isn’t “cumulative across the whole page lifetime” anymore — since 2021, it’s “the largest shift cluster in any 5s session window.” Long-lived SPAs aren’t penalized for occasional cumulative drift.
- INP requires Chrome 96+ field data — the metric exists in older browsers but isn’t reported the same way.
- Lab LCP ≠ field LCP — lab simulates one load; field reflects warmed caches, varied networks, etc.
<img loading="lazy">on the LCP image hurts LCP — lazy-loading delays the very thing you’re trying to render fast. Eager +fetchpriority="high"for the hero.requestAnimationFramecallbacks count toward INP if they’re after an interaction. Long rAF work after a click adds latency.- CLS during navigation (Next.js route change, etc.) doesn’t count — it’s only “unexpected” shifts on a stable page.
What a senior is expected to say 6
- “Three vitals: LCP (visible content), INP (responsiveness), CLS (stability). Each measured at p75 of real users — field data, not lab. INP replaced FID in 2024.”
- “Budget per route, per metric, gated in CI. A budget without enforcement is a wish.”
- “LCP: identify the element, trace why it loaded late (TTFB, render-block, image discovery).
fetchpriority='high'on the hero is the cheapest win.” - “INP: long tasks blocking the main thread. Break up work with
scheduler.yield, move heavy work to Workers, mark non-urgent updates withuseTransition.” - “CLS: reserve space — width/height/aspect-ratio on images, min-height on dynamic containers,
font-display: swap+ matching metrics.” - “I use
web-vitalswith/attributionto capture not just the metric but what caused it — element selector, slow handler, etc.”
Cross-references
- Rendering pipeline that determines LCP/CLS: The Critical Rendering Path and the Rendering Pipeline
- Image and font perf (most LCP/CLS issues live here): Image and Font Optimization
- Resource hints to fix late-discovered LCP resources: Resource Hints — preload, prefetch, preconnect, dns-prefetch, modulepreload
- React-specific INP via
useTransition: React
Further reading
- web.dev — Core Web Vitals: https://web.dev/articles/vitals
- web.dev — INP: https://web.dev/articles/inp
web-vitalslibrary: https://github.com/GoogleChrome/web-vitals- CrUX (real-user data): https://developer.chrome.com/docs/crux/overview