Core Web Vitals Deep Dive
Understand and fix LCP, INP, and CLS — with a developer’s debugging workflow.
Core Web Vitals are Google’s standardized way of measuring page experience — how fast a page renders, how quickly it responds to input, and how stable it stays while loading. They roll up into the broader page experience signal, which acts as a tie-breaker in ranking: it rarely outranks great content, but a slow, janky page loses to an equally relevant fast one.
There are exactly three Core Web Vitals today, and one of them is new. As of March 2024, INP (Interaction to Next Paint) replaced FID (First Input Delay) as the responsiveness metric. FID only measured the delay before the first interaction was processed; INP measures the full latency of interactions across the whole page lifetime. If your mental model still says “FID,” update it — FID is deprecated and no longer reported.
This guide assumes you can read code and ship changes. We’ll go metric by metric, separate the data sources that actually matter, then walk a concrete debugging loop you can run on any page.
The three metrics
Each metric has three threshold buckets. Google evaluates the 75th percentile of real users — so you need three out of four page loads to clear the “Good” bar, not your median dev-machine experience.
| Metric | What it measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the largest visible element (hero image, heading, video poster) is painted | ≤ 2.5 s | 2.5 – 4.0 s | > 4.0 s |
| INP (Interaction to Next Paint) | Worst-case latency from a user interaction to the next frame painted | ≤ 200 ms | 200 – 500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | Sum of unexpected layout shift scores during the page’s lifetime | ≤ 0.1 | 0.1 – 0.25 | > 0.25 |
A useful way to hold these in your head: LCP is “is it there yet?”, INP is “did it react when I poked it?”, CLS is “did it move while I was reading?” Loading, responsiveness, visual stability.
🧑💻 Developer perspective: CLS is unitless — it’s
impact fraction × distance fractionsummed across shift windows, not milliseconds. A single big shift near the top of the viewport can blow your whole budget, while many tiny shifts below the fold may barely register.
Field vs lab data
This distinction trips up more teams than any single optimization. There are two kinds of measurement and they answer different questions.
Lab data comes from a synthetic run in a controlled environment — Lighthouse, WebPageTest, or the Performance panel. One device, one network profile, one cold load. It’s reproducible and great for debugging, but it is one sample and it cannot measure INP properly, because INP needs real humans clicking real things over a real session. Lighthouse substitutes Total Blocking Time (TBT) as a lab proxy for responsiveness.
Field data is what Google actually ranks on. It comes from the Chrome User Experience Report (CrUX) — anonymized, aggregated metrics from real Chrome users who opted into reporting, bucketed over a rolling 28-day window. This is “RUM” (Real User Monitoring) at planet scale.
| Lab (Lighthouse) | Field (CrUX / RUM) | |
|---|---|---|
| Source | Synthetic single run | Real users, 28-day window |
| INP support | No (uses TBT proxy) | Yes |
| Used for ranking | No | Yes |
| Good for | Debugging, CI gates | Ground truth, prioritization |
| Variability | Low (controlled) | High (real devices/networks) |
⚠️ Note: A green Lighthouse score does not mean your Core Web Vitals are “Good.” Lighthouse runs on a throttled-but-clean simulated device; your real users include mid-tier Android phones on flaky networks. Always confirm with field data before declaring victory.
The practical rule: debug in the lab, judge by the field. Use Lighthouse to find and fix issues fast (it’s deterministic), but treat CrUX in Search Console or PageSpeed Insights as the source of truth for whether the fix landed.
Fixing LCP
LCP is the most decomposable metric. Break the time-to-LCP into four sub-phases and you’ll know exactly where to spend effort:
| Sub-phase | What’s happening | Typical share of a slow LCP |
|---|---|---|
| TTFB | Server responds with first byte | often 40%+ |
| Resource load delay | Time between TTFB and the browser starting to load the LCP resource | the most common hidden culprit |
| Resource load time | Downloading the LCP image/font/video | network-bound |
| Element render delay | Time between resource arriving and it being painted | blocked by CSS/JS |
Optimal LCP keeps load delay near zero — the browser should discover and start fetching the LCP resource almost immediately.
1. Cut TTFB. This is server-side: caching, CDN edge delivery, and avoiding render-blocking origin work. For a static site served from a CDN, TTFB should be double-digit milliseconds. If it’s hundreds, you have a caching or origin problem before you touch a single image.
2. Preload the LCP resource. The classic load-delay bug: the LCP image is referenced in CSS or injected by JS, so the browser doesn’t discover it until late. Make it discoverable in the initial HTML and hint its priority:
<!-- Preload + high priority so the browser fetches it immediately -->
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<!-- Or directly on the img — fetchpriority avoids a separate preload -->
<img src="/hero.avif" fetchpriority="high" alt="Product hero" width="1280" height="720" />
3. Optimize the image itself. Serve modern formats (AVIF/WebP), size it to the actual rendered dimensions, and use responsive srcset. Never lazy-load your LCP image — loading="lazy" on the hero is a self-inflicted LCP wound, because it defers the most important paint.
<img
src="/hero-800.avif"
srcset="/hero-400.avif 400w, /hero-800.avif 800w, /hero-1600.avif 1600w"
sizes="(max-width: 600px) 100vw, 800px"
fetchpriority="high"
alt="Product hero"
width="800" height="450" />
4. Handle fonts. If your LCP element is text (a big heading), a blocking web font delays render. Preload the font, use font-display: swap (or optional), and self-host to skip a third-party connection.
@font-face {
font-family: "Inter";
src: url("/fonts/inter.woff2") format("woff2");
font-display: swap; /* render text immediately, swap when font loads */
}
💡 Tip: Before optimizing, identify which element is the LCP. In DevTools, the Performance panel marks it explicitly, and PageSpeed Insights names it. Optimizing the wrong element is the most common wasted afternoon in performance work.
Fixing INP
INP is a main-thread problem. When a user clicks, the browser needs to run your event handler, recalculate styles and layout, then paint the next frame. If the main thread is busy — running a long task — none of that can happen, and the interaction feels frozen.
The single highest-leverage move is breaking up long tasks (anything over 50 ms blocks the main thread). Three techniques, from blunt to surgical:
1. Yield to the main thread. Let the browser handle pending input between chunks of work. The modern primitive is scheduler.yield(); setTimeout(0) is the fallback.
async function processLargeList(items) {
for (const item of items) {
doExpensiveWork(item);
// Yield so a pending click can be processed mid-loop
if (navigator.scheduling?.isInputPending?.()) {
await scheduler.yield(); // falls back to setTimeout in older browsers
}
}
}
2. Decouple the visual response from the heavy work. Paint the feedback the user expects first, then defer the expensive computation so it doesn’t block the next paint:
button.addEventListener("click", () => {
// 1. Immediate visual feedback — runs before the next paint
button.classList.add("is-loading");
// 2. Defer the heavy work past the paint
requestAnimationFrame(() => {
setTimeout(() => runExpensiveUpdate(), 0);
});
});
3. Avoid unnecessary re-renders. In component frameworks, a single click that triggers a cascade of re-renders is a classic INP killer. Memoize, debounce high-frequency handlers, and keep state updates scoped:
// React: debounce a search-as-you-type handler so each keystroke
// doesn't trigger a full filter + re-render on the critical path
const onChange = useMemo(
() => debounce((q) => setQuery(q), 150),
[]
);
Other reliable wins: move CPU-heavy work (parsing, image processing) into a Web Worker; trim third-party scripts that hog the main thread; and avoid synchronous layout reads inside handlers (reading offsetHeight then writing styles forces “layout thrashing”).
🧑💻 Developer perspective: INP measures the worst interaction, not the average. One slow modal-open on an otherwise snappy page can tank your field score. Profile the interactions users actually perform most — menu toggles, search, add-to-cart — not just page load.
Fixing CLS
CLS is almost always caused by content that loads after layout and pushes existing content around. The fixes are about reserving space ahead of time.
1. Always set dimensions on images and video. Width/height attributes (or a CSS aspect-ratio) let the browser reserve the box before the image arrives:
<img src="/photo.avif" width="800" height="450" alt="..." />
.media { aspect-ratio: 16 / 9; width: 100%; }
2. Reserve space for ads, embeds, and iframes. Dynamically injected ad slots are the #1 CLS source on content sites. Give the container a fixed min-height matching the most common slot size so the page doesn’t jump when the ad fills in.
.ad-slot { min-height: 280px; } /* reserve before the ad loads */
3. Never insert content above existing content. Banners, cookie notices, and “you have a new message” bars that push the page down are the worst offenders. Overlay them (fixed/absolute positioning) instead of inserting them into the flow, or reserve their space from the start.
4. Tame font swaps. A font swap changes text metrics and can shift everything below it. Pair font-display: swap with the size-adjust, ascent-override, and descent-override descriptors on a fallback @font-face so the fallback and web font occupy the same space:
@font-face {
font-family: "Inter-fallback";
src: local("Arial");
size-adjust: 107%;
ascent-override: 90%;
descent-override: 22%;
}
⚠️ Note: CLS only counts unexpected shifts. A shift within 500 ms of a user interaction (e.g. expanding an accordion) is excluded. So you don’t need to fear every animation — only movement the user didn’t ask for.
A debugging workflow
Here’s a repeatable loop. Move from cheap-and-broad to deep-and-specific.
Step 1 — Triage with field data. Open PageSpeed Insights (or Search Console’s Core Web Vitals report) and read the field section first. This tells you which metric is actually failing at the 75th percentile and on which device class (mobile usually loses first). Don’t optimize what isn’t broken.
Step 2 — Reproduce in the lab. Run Lighthouse (DevTools → Lighthouse, mobile + throttling on) to get a deterministic, debuggable run with specific diagnostics (“Largest Contentful Paint element,” “Avoid large layout shifts,” “Reduce unused JavaScript”).
Step 3 — Profile the specific metric in DevTools.
- For LCP/CLS: the Performance panel records a trace. It marks the LCP element and flags every layout shift with a red “Layout Shift” track — click one to see exactly which node moved.
- For INP: enable the interaction track, click around the page, and inspect the longest interaction. DevTools breaks it into input delay, processing time, and presentation delay so you know whether the problem is a busy main thread (input delay) or a slow handler (processing).
Step 4 — Instrument with the web-vitals library. Lab data can’t capture real INP. Drop in the official library to collect field metrics from your own users and beacon them to your analytics:
import { onLCP, onINP, onCLS } from "web-vitals";
function send(metric) {
navigator.sendBeacon(
"/analytics",
JSON.stringify({ name: metric.name, value: metric.value, id: metric.id })
);
}
onLCP(send);
onINP(send);
onCLS(send);
Each metric object carries an attribution build (web-vitals/attribution) that tells you the offending element or the largest shift source — so your RUM data points straight at the fix, not just a number.
Step 5 — Verify in the field. After shipping, wait. CrUX uses a rolling 28-day window, so field improvements take weeks to fully reflect. Your own web-vitals beacons will show movement within days — use them for fast feedback, and confirm against PageSpeed Insights once the window catches up.
Where this connects
Core Web Vitals are downstream of decisions you make when building the site — your rendering strategy, asset pipeline, font loading, and hosting all set the ceiling for how good these numbers can be. Optimizing them after the fact is harder than building them in.
For the foundations — how to structure a fast, crawlable site from the start — continue to the Site Build layer. That layer covers the architecture choices (static rendering, image pipelines, CDN delivery) that make hitting these thresholds the default rather than a fight.
Key takeaways
- ✅ Three metrics, three questions: LCP (is it there?), INP (did it react?), CLS (did it move?). Targets: ≤ 2.5 s, ≤ 200 ms, ≤ 0.1 at the 75th percentile.
- ✅ INP replaced FID in 2024 — measure full interaction latency across the session, not just the first input.
- ✅ Debug in the lab, judge by the field. A green Lighthouse score is not a passing CrUX score.
- ✅ Fix LCP by decomposing it: cut TTFB, kill load delay with
preload+fetchpriority, and never lazy-load the hero. - ✅ Fix INP by breaking up long tasks — yield with
scheduler.yield(), paint feedback first, and cut needless re-renders. - ✅ Fix CLS by reserving space up front: image dimensions, ad-slot
min-height, overlay (don’t insert) banners, and a metric-matched fallback font.