Topics
Web Performance

Core Web Vitals

What LCP, INP and CLS actually measure in the browser, the fixes that move each one, and why a green Lighthouse score can go with red field data.

Intermediate·14 min read·Updated Sep 28, 2026

Core Web Vitals are three numbers Chrome measures on real visits and Google reads for ranking: LCP (Largest Contentful Paint, when the biggest thing in the viewport rendered, target ≤ 2.5 s), INP (Interaction to Next Paint, the slowest response to a tap or keypress, target ≤ 200 ms) and CLS (Cumulative Layout Shift, how much the page jumped around, target ≤ 0.1). Each one is the visible symptom of a specific browser mechanism, so each has a short list of fixes that actually move it, and a longer list that only moves a lab score.

Context

For most of the web's history "page speed" meant the load event: when every resource had finished. Single-page apps broke that number (a blank page with a spinner "loads" in 300 ms), so Google's Chrome team spent 2016-2019 defining user-centric metrics for Lighthouse: First Contentful Paint, Time to Interactive, Speed Index. In May 2020 they picked three of them as the Web Vitals initiative: LCP, FID (First Input Delay) and CLS. In 2021 those became a ranking signal in Google Search. On 12 March 2024 INP replaced FID, because FID measured only the delay before the first handler ran and let sluggish apps pass.

The numbers come from two places. Field data is collected from real Chrome users who opted in, aggregated per origin and per URL over a rolling 28 days in the Chrome User Experience Report (CrUX), and reported at the 75th percentile. That is what Search Console, PageSpeed Insights' top section and the ranking signal use. Lab data is one synthetic run in Lighthouse on a throttled connection. You have met both as the green and red circles in PageSpeed Insights, as a Search Console email about "CLS issues on mobile", or as reportWebVitals in a Next.js app.

The simplest way to see your own numbers is three lines in any page:

vitals.js
import {onLCP, onINP, onCLS} from 'web-vitals'
onLCP(console.log)   // {name: 'LCP', value: 1840, rating: 'good', …}
onINP(console.log)   // reported when the page is hidden, after interactions
onCLS(console.log)
Field vs lab
Field: real users, real devices, p75 over 28 days. Lab: one scripted run in Lighthouse or DevTools. Only field data affects ranking.
p75
The value 75% of page views are at or better than. A page passes when its p75 is under the threshold, so the slow quarter of visits decides.
Main thread
The single thread that parses HTML, runs JavaScript, computes style and layout, and paints. Everything in INP happens here.
Long task
Any main-thread task over 50 ms. While it runs, no input can be handled and nothing can paint.
Layout shift
A visible element changing position between two frames without a user interaction causing it.

Why it matters

The ranking effect is modest but real, and the conversion effect is larger: field studies from retailers and Google consistently show bounce rates climbing steeply between 1 and 3 seconds of LCP. More practically, these are the only performance numbers a non-engineer will ever show you, they arrive as a Search Console alert after a deploy, and the common response, running Lighthouse until it is green, often does nothing for them because Lighthouse cannot measure INP and runs on a machine your users do not have.

LCP: the largest thing in the viewport

The browser watches every image, video poster, background image and text block that paints inside the viewport, tracks the largest so far, and stops when the user first interacts. The final candidate's render time is LCP. Because it is one element, the metric decomposes into four phases of that element's journey, and the fix depends on which phase is long.

navigation startLCP paintTTFBload delayresource load timerender delayserver, CDN, redirectsdiscovered late?bytes, format, priorityblocked by CSS/JSgood ≤ 2.5 s · needs improvement ≤ 4 s · poor > 4 s (p75)
LCP for a hero image, broken into its four sub-parts. Most slow LCPs are dominated by one of them; the web-vitals attribution build tells you which.
PhaseLong becauseFix
TTFBTime to first byte: slow origin, no CDN, redirect chains, cold serverlessCache the HTML at the edge, stream the response so the head arrives early, cut redirects (http→https→www is three round trips).
Load delayImage is discovered late: CSS background, JS-inserted, lazy-loaded, behind a client-side fetchPut it in the initial HTML as an <img>, preload it, never loading="lazy" on the hero, render it on the server.
Load timeToo many bytes, competing with 40 other requestsfetchpriority="high", modern formats (AVIF/WebP), responsive srcset, correct dimensions, a CDN close to the user.
Render delayImage arrived but render-blocking CSS or a JS bundle held the frame, or the element only mounts after hydrationInline critical CSS, defer non-critical scripts, keep the LCP element out of client-only components.
slow.html
<!-- hero is a CSS background: found only after the stylesheet loads -->
<link rel="stylesheet" href="/app.css">
<div class="hero"></div>

<!-- or: hero rendered by JS after a fetch, lazy-loaded to "save bytes" -->
<img loading="lazy" src="/hero.jpg">
fast.html
<link rel="preload" as="image" href="/hero.avif"
      imagesrcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
      imagesizes="100vw">

<img src="/hero.avif" srcset="/hero-800.avif 800w, /hero-1600.avif 1600w"
     sizes="100vw" width="1600" height="900"
     fetchpriority="high" decoding="async" alt="…">
<!-- in the HTML, high priority, sized, discovered by the preload scanner -->

INP: the slowest interaction

Every click, tap and keypress is measured from the input event to the next frame the browser paints. INP is roughly the worst of them over the page's life (for pages with many interactions, one outlier per 50 is ignored). The measurement covers three phases, and each is a different bug.

inputuser taps
input delaymain thread busy with something else
processingyour event handlers run
presentation delaystyle, layout, paint of the result
next paintuser sees the response
One interaction. INP is the whole span. FID, the metric it replaced, measured only the first phase of the first interaction.
PhaseTypical causeFix
Input delayA long task was running: hydration, a third-party script, a timer doing analytics, a previous interaction still processingBreak long tasks so input can slip between them; defer third-party scripts; reduce hydration work (server components, lazy islands).
ProcessingHandlers that do synchronous heavy work, or React re-rendering a large tree on every keystrokeDo the visible update first and yield, then the rest; startTransition for non-urgent state; memoise or virtualise big lists; move computation to a Web Worker.
Presentation delayHuge DOM, expensive CSS selectors, layout thrashing (read layout, write style, read layout in a loop), heavy paintSmaller DOM, avoid forced synchronous layout, animate transform/opacity only, contain: content on independent regions.
blocking.js
button.addEventListener('click', () => {
  showSpinner()            // queued in the same task...
  const report = buildReport(rows)   // ...so this 400 ms blocks it
  render(report)
})
// INP ≈ 400 ms: the spinner and the result paint in the same frame
yielding.js
button.addEventListener('click', async () => {
  showSpinner()
  await scheduler.yield()   // Chrome 129+; fallback below
  const report = buildReport(rows)
  render(report)
})
// spinner paints in ~16 ms, INP ≈ 30 ms; the work still takes 400 ms

const yieldToMain = () =>
  'scheduler' in window && 'yield' in scheduler
    ? scheduler.yield()
    : new Promise(r => setTimeout(r, 0))

CLS: things moving without being asked

Each time a visible element moves between two frames without a user interaction in the previous 500 ms, the browser records a layout shift with a score of impact fraction (how much of the viewport was affected) times distance fraction (how far it moved, relative to the viewport). Shifts are grouped into session windows of up to 5 seconds with a 1-second gap between them; CLS is the score of the worst window. A single late banner pushing the whole page down by 20% of the screen scores about 0.2, which is already "poor".

CauseWhy it shiftsFix
Images, iframes
without dimensions
The box is 0 px tall until the bytes arrive, then growswidth/height attributes or CSS aspect-ratio; the browser reserves the space before loading.
Ads, embeds, banners
injected above content
Inserted after first paint, pushing everything belowReserve a min-height slot, or insert below the viewport / as an overlay that does not affect flow.
Web fontsFallback font renders, web font arrives with different metrics, text reflowsfont-display: optional (no swap after the first frames) or matched fallback metrics with size-adjust; next/font does both.
Animating top / left /
width / height
Every frame is a layoutAnimate transform and opacity, which are composited and do not count as shifts.
Client-side data
arriving after render
Skeleton is a different size from the content, or nothing was reservedServer-render the content, or make the skeleton the same size as the loaded state.
stable.css
img, video { max-width: 100%; height: auto; }   /* keeps width/height ratio */
.card-image { aspect-ratio: 16 / 9; }             /* reserves space before load */
.ad-slot    { min-height: 250px; }                /* banner cannot push content */

@font-face {
  font-family: 'Inter';
  src: url('/inter.woff2') format('woff2');
  font-display: optional;                         /* never swap late */
}
@font-face {
  font-family: 'Inter Fallback';                  /* metric-matched fallback */
  src: local('Arial');
  size-adjust: 107%; ascent-override: 90%; descent-override: 22%;
}

Measuring: field first, lab to debug

Lab tools answer "what could be slow?"; only field data answers "what is slow for our users?". They disagree constantly, because the lab runs one URL on one simulated phone with an empty cache and no interactions, and the field is the slowest quarter of real visits on real hardware, mid-scroll, mid-tap.

SourceWhat it isUse it for
CrUX
(Search Console, PSI, API)
p75 of real Chrome visits, 28-day window, per URL and per origin. Needs enough traffic.The number that counts for ranking. Slow to move: a fix shows up over four weeks.
Your own RUM
(real user monitoring)
The web-vitals library reporting to your analytics: every visit, every browser you instrument, with attribution: which element was the LCP, which handler was slow, which node shiftedFinding the cause, segmenting by device or route, seeing a fix the same day.
Lab tools
(Lighthouse, DevTools)
One synthetic run on a throttled profile; no INP (it reports Total Blocking Time as a proxy)Reproducing and debugging a known problem; catching regressions in CI with thresholds.
rum.ts (send field data with attribution)
import {onLCP, onINP, onCLS} from 'web-vitals/attribution'

function send(metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,                 // good | needs-improvement | poor
    route: location.pathname,
    // what to fix, not just how bad:
    target: metric.attribution.target,     // e.g. 'img.hero' or 'button#buy'
    detail: metric.name === 'LCP'
      ? metric.attribution.resourceLoadDelay
      : metric.attribution.interactionType,
  })
  navigator.sendBeacon('/api/vitals', body)   // survives page unload
}
onLCP(send); onINP(send); onCLS(send)

Pitfalls

  • Optimising the Lighthouse score

    The score is a lab composite that does not include INP and is measured on a laptop pretending to be a phone. Teams reach 95 and keep failing in Search Console, because their p75 user is on a mid-range Android on cellular with four tabs open. Pick the field metric that is red, use attribution to find the element or handler responsible, fix that, and wait for CrUX.

  • Lazy-loading the LCP image

    loading="lazy" tells the browser to wait until the image is near the viewport, which for a hero means "after layout", so it is discovered late and fetched at low priority. Lazy-load everything below the fold and nothing above it; give the hero fetchpriority="high" instead.

  • font-display: swap as the "fix" for fonts

    It fixes invisible text but guarantees a reflow when the web font arrives, and on slow connections that reflow lands after first paint and counts as CLS. Use optional so a late font is simply skipped for this visit, or match the fallback's metrics so the swap does not move anything.

  • Fixing CLS by delaying content

    Hiding the page until every asset has loaded removes shifts and moves LCP by seconds. Reserve space instead of delaying paint; the goal is a stable page that appears early, not a stable page that appears late.

  • Hydrating everything, then blaming the server

    A large client bundle makes INP bad in a way TTFB fixes cannot touch: hydration is one long task during which every tap waits, and re-rendering a large tree on each interaction keeps processing time high. Ship less JavaScript to the page (server components, code splitting, islands) before tuning what remains.

Interview questions

Q1What are the three Core Web Vitals, and what does each one measure?

LCP is the time until the largest image or text block in the viewport has rendered, target 2.5 seconds: it stands for loading. INP is the slowest interaction on the page from input to the next painted frame, target 200 milliseconds: responsiveness. CLS is the total score of unexpected layout shifts in the worst five-second window, target 0.1: visual stability. All three are judged at the 75th percentile of real Chrome visits over 28 days.

Q2Why did INP replace FID?

FID measured only the delay before the first interaction's handler started, so a page whose handlers took 300 ms to run, or that was slow on the tenth click, still passed. INP measures the full time to the next paint, including processing and rendering, for every interaction, and reports the worst. It is a much harder metric, and when it went live in March 2024 many sites that passed FID failed INP.

Q3Walk me through improving LCP on a product page with a hero image.

First find which phase is long using attribution data. If TTFB, cache the HTML at the edge and stream it. If load delay, make sure the image is a plain img in the server-rendered HTML, preloaded, not lazy-loaded, and not waiting for a client fetch. If load time, serve AVIF or WebP at the right size via srcset with fetchpriority high from a CDN. If render delay, inline critical CSS and defer scripts so the frame is not blocked. Then confirm in field data, not Lighthouse.

Q4What causes layout shift, and how do you handle web fonts specifically?

Anything that changes size or position after first paint without user input: images without dimensions, injected banners or ads, late content, and fonts swapping in with different metrics. For fonts I either use font-display optional, so a font that misses the first frames is skipped for that visit, or define a fallback with size-adjust and ascent and descent overrides that match the web font so the swap does not move text. Tools like next/font generate that automatically.

Q5Lighthouse gives 96 but Search Console says the page fails Core Web Vitals. What is happening?

Lighthouse is one lab run on a simulated device with no interactions, and it cannot measure INP at all; Search Console reports the 75th percentile of real users over 28 days. The failing quarter are likely slower devices or networks, a different route in the same URL group, or interactions Lighthouse never performs. I would instrument the page with web-vitals attribution, segment by device and route, and fix what the field data points at.

Q6How does a long task hurt INP, and what do you do about it?

The main thread cannot handle input or paint while a task runs, so a tap that arrives during a 300 ms task waits up to 300 ms before its handler even starts, and a handler that itself runs 300 ms delays the paint by that much. The fix is to break work into chunks under 50 ms and yield between them with scheduler.yield or a setTimeout, update the visible state first and defer the rest, and move anything that does not need the DOM into a worker.

Q7How would you measure Core Web Vitals in production?

Add the web-vitals library's attribution build, send each metric with its target element, route and device class to an endpoint via sendBeacon, and chart p75 per route. Compare with CrUX for the official numbers, and keep Lighthouse in CI with thresholds to catch regressions before they ship. The field dashboard tells me what is wrong and for whom; the lab reproduces it.

Key takeaways
  • LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, all at p75 of real Chrome visits over 28 days. Field data decides; lab data debugs.
  • LCP is one element's journey: TTFB, discovery, download, render. Put the hero in the HTML, preload it, fetchpriority high, never lazy.
  • INP is input delay plus handler time plus paint. Break long tasks, yield after the visible update, ship less JavaScript to hydrate.
  • CLS is size times distance of unexpected moves. Reserve space with dimensions and aspect-ratio, keep injected content out of flow, use font-display optional or metric-matched fallbacks.
  • Lighthouse cannot measure INP and runs on a machine your users do not have; a green score with red field data is normal, not a contradiction.
  • Instrument with web-vitals attribution and segment by route and device; the slowest quarter of visits is where the metric lives.

Preparing for interviews? DevRecall turns a job description into a prep plan that points at topics like this one.

Start free