DevgainsDevgainsDevgains
All articles

Core Web Vitals: The Complete Guide to LCP, INP, and CLS

·9 min read
Core Web Vitals: The Complete Guide to LCP, INP, and CLS

Cover: gradient generated for Devgains

Core Web Vitals are the three field metrics Google uses to summarize how a real page feels to a real user: how fast the main content loads (LCP), how quickly the page responds when you interact with it (INP), and how much the layout jumps around while it settles (CLS). Get those three into the "good" band and you have covered the parts of performance users actually notice — and the parts that feed into Google's ranking signals. This guide is the hub for that work: what each metric measures, the thresholds that matter, why your lab score and your field score disagree, and a prioritized plan to fix each one.

Most performance advice fails because it optimizes a number no user feels. Core Web Vitals are deliberately the opposite — each one is a proxy for a specific moment of frustration. So before any tooling, it is worth being precise about what the three Web Vitals are actually trying to capture.

What Core Web Vitals measure (and the thresholds)

There are three, and each maps to a distinct user experience. Google grades them at the 75th percentile of real visits — meaning three out of four page loads must clear the bar, not just your fast ones.

MetricMeasuresGoodNeeds workPoor
LCP (Largest Contentful Paint)Loading: when the biggest visible element renders≤ 2.5 s≤ 4.0 s> 4.0 s
INP (Interaction to Next Paint)Responsiveness: input → next painted frame≤ 200 ms≤ 500 ms> 500 ms
CLS (Cumulative Layout Shift)Visual stability: unexpected layout movement≤ 0.1≤ 0.25> 0.25

"Good" requires the 75th-percentile value of all three to be in the green band over a 28-day window. A great LCP cannot rescue a poor INP — they are scored independently, and the page is only counted as passing when every metric passes.

The rest of this guide takes them one at a time, because the fixes for each are almost entirely different. LCP is a loading problem, INP is a main-thread problem, and CLS is a layout problem — confusing them is the most common reason teams "optimize" for weeks and move nothing.

Largest Contentful Paint: the loading metric

LCP marks the moment the largest element in the viewport — usually a hero image, a heading, or a big block of text — finishes rendering. It is the closest single number to "the page looks loaded." On the majority of pages that element is an image, which is why image optimization is the single biggest LCP win: fix the format, dimensions, and priority of one image and you often move the whole metric.

LCP breaks down into four sub-parts you can attack in order: time to first byte, resource load delay, resource load time, and render delay. The two that dominate in practice are a slow or late-discovered LCP resource (the image isn't preloaded, or it sits behind render-blocking CSS/JS) and a heavy render path. Concretely:

  • Serve the LCP image in a modern format, correctly sized, with fetchpriority="high" and no lazy-loading on the hero.
  • Remove render-blocking resources from the critical path — much of which is just JavaScript you can defer or delete.
  • Make sure the LCP element is discoverable in the initial HTML, not injected by a script after hydration.

For the full mechanics, web.dev's LCP reference is the canonical source.

Interaction to Next Paint: the responsiveness metric

INP replaced First Input Delay as a Core Web Vital in March 2024, and it is the metric that most often quietly fails — which is why INP is the Core Web Vital that broke your Lighthouse score without anyone shipping an obvious regression. Where FID only measured the delay before an interaction was handled, INP measures the whole interaction: from the tap or keypress until the browser paints the next frame that reflects it. It reports roughly the worst interaction across the visit, so one janky handler poisons the score.

INP is almost always a main-thread problem. Long tasks — a chunk of JavaScript that runs for more than 50 ms without yielding — block the browser from painting, so the UI feels stuck. The usual culprits:

  • Heavy event handlers. Expensive work on input, scroll, or resize that you can debounce or throttle so it stops running on every frame.
  • Wasted React work. Components that re-render far more than they need to — the cost of re-renders and the memoization that actually helps is mostly an INP story.
  • Big synchronous updates that should be broken up or deferred so the browser can paint between chunks.

A reliable pattern is to yield to the main thread after doing the visible part of the work, letting the browser paint before you finish the rest:

function onClick() {
  updateUIImmediately();        // cheap, visible — paints fast, good INP
 
  // Hand control back to the browser so it can paint,
  // then continue the expensive part on the next task.
  setTimeout(() => {
    doExpensiveWork();          // analytics, recompute, prefetch
  }, 0);
}

The INP reference covers attribution — how to find which interaction and which script phase is costing you.

Cumulative Layout Shift: the stability metric

CLS measures how much visible content jumps around unexpectedly while the page settles. It is the metric behind the universal annoyance of reaching for a button and having an ad or banner shove it out from under your finger. Unlike LCP and INP it has no time unit — it is a unitless score combining how much of the viewport moved and how far.

The fixes are mechanical and high-leverage:

  • Always reserve space. Set explicit width and height (or aspect-ratio) on images, videos, and ad slots so the browser lays out the right box before the asset arrives.
  • Don't inject content above existing content. Banners, cookie notices, and "you have a new message" toasts should overlay or push from the bottom, not insert at the top and shove everything down.
  • Reserve space for web fonts and prefer font-display: optional or swap with a matched fallback so text doesn't reflow when the custom font loads.

You can watch shifts happen in real time with a PerformanceObserver, which is also how the official library computes the metric:

let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Ignore shifts that happen right after user input — those are expected.
    if (!entry.hadRecentInput) {
      cls += entry.value;
      console.log("layout shift:", entry.value, "running CLS:", cls);
    }
  }
}).observe({ type: "layout-shift", buffered: true });

See the CLS reference for how shifts are grouped into session windows.

Field data vs lab data: why your scores disagree

This is the trap that wastes the most time. Lab data comes from a synthetic run — Lighthouse, a DevTools audit — on one device, one network, with no real users. Field data (also called RUM, or in Google's case CrUX) is collected from actual visitors on actual hardware. Core Web Vitals assessment uses field data. Your Lighthouse score is a useful debugging tool, but it is not the thing Google grades.

The two diverge constantly. INP barely shows up in Lighthouse because lab runs don't click anything — you need real interactions to surface it. LCP in the lab uses one simulated connection; in the field you have users on three-year-old Android phones over flaky mobile networks at the 75th percentile. Always confirm wins against field data before declaring victory; a green lab score over a red field score means you optimized the wrong machine. When you do need to dig into real React workloads, the patterns from profiling 50 production React apps show what actually moves field numbers.

How to measure Core Web Vitals in your own app

Google publishes the web-vitals library, which computes all three exactly the way CrUX does and hands you values you can ship to your own analytics. This is the most reliable way to build a field dataset you control:

import { onLCP, onINP, onCLS } from "web-vitals";
 
function report({ name, value, rating, id }) {
  // Send to your analytics endpoint. `rating` is "good" | "needs-improvement" | "poor".
  navigator.sendBeacon(
    "/analytics/vitals",
    JSON.stringify({ name, value, rating, id })
  );
}
 
onLCP(report);
onINP(report);
onCLS(report);

Collect those over real traffic, look at the 75th percentile per metric, and you have the same view Google uses — segmented by your own routes and device classes, which CrUX won't give you.

A prioritized plan

Don't optimize all three at once. Pull your field data, find the metric that is poor at the 75th percentile, and work it to green before touching the next:

  1. LCP poor? Start with the LCP image and the critical request chain — biggest, fastest wins.
  2. INP poor? Hunt long tasks: heavy handlers, runaway re-renders, big synchronous updates.
  3. CLS poor? Reserve space for every async-loaded box: images, ads, fonts, late banners.
  4. Re-measure in the field. A 28-day window means changes take time to show — be patient and verify.

Takeaways

  • Three metrics, three different problems. LCP is loading, INP is main-thread responsiveness, CLS is layout stability. The fixes barely overlap.
  • Thresholds: LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, all judged at the 75th percentile. A page passes only when all three pass.
  • Field data is what's graded, not Lighthouse. Confirm every win against real-user data before you believe it.
  • Measure with the web-vitals library so you own a field dataset segmented by route and device.
  • Fix one metric at a time, starting with whichever is poor at p75.

Ready to go deeper? Browse the Performance cluster for the component guides — image optimization for LCP, INP and the main thread, and cutting bundle size.

FAQ

Are Core Web Vitals a ranking factor? Yes, as part of Google's page experience signals, though content relevance still dominates. They are best treated as a tie-breaker and a real UX win rather than a primary SEO lever — Google's documentation is explicit that great content outranks a fast page with thin content.

Why is my Lighthouse score green but my Core Web Vitals failing? Because Lighthouse is lab data from one synthetic run, while the assessment uses field data from real users at the 75th percentile. INP especially almost never shows up in the lab because synthetic runs don't interact with the page.

What happened to FID? First Input Delay was retired as a Core Web Vital in March 2024 and replaced by INP, which measures the full interaction-to-paint latency instead of only the initial input delay.

How long until changes show up? Core Web Vitals use a rolling 28-day window of field data, so a fix won't fully reflect in your assessment for several weeks even though your own RUM will show the improvement much sooner.

9 min read

Read next