Web Development Performance

Core Web Vitals in Plain English: LCP, INP, and CLS

Published

Three metrics, three questions a real visitor is silently asking: did anything show up, did the page react when I tapped, and did it stop moving? Here are the thresholds, why your lab score disagrees with your field data, and what actually moves each number.

Core Web Vitals get treated like an exam you cram for, which is a shame, because the three metrics are unusually honest. Each one is a stand-in for a question a real person asks without noticing they are asking it. Did anything show up? Did the page react when I tapped it? Did the thing I was about to tap move? Once you read them that way the optimization work stops being cargo cult and starts being obvious.

The three metrics and their thresholds

Core Web Vitals thresholds, measured at the 75th percentile
MetricGoodNeeds workPoor
LCP — Largest Contentful Paint≤ 2.5 s2.5 – 4.0 s> 4.0 s
INP — Interaction to Next Paint≤ 200 ms200 – 500 ms> 500 ms
CLS — Cumulative Layout Shift≤ 0.10.1 – 0.25> 0.25

The phrase 75th percentile is doing a lot of work in that caption, and it is the part people skip. You do not need your average visit to be fast. You need three out of four visits to clear the bar, which means the slow tail is what you are actually fighting. Your own laptop on office wifi is somewhere around the 5th percentile and tells you almost nothing.

Note also that INP is not the old metric with a new name. INP replaced First Input Delay in March 2024. FID measured only the delay before an event handler started. You could pass FID comfortably while every button on the page took a second to visibly do anything, because the waiting happened after the part FID was watching. INP measures the whole trip: input to the next frame the user can actually see. It is a much harder metric to fool, and plenty of sites that sailed through FID discovered they had an interaction problem all along.

Where each metric lives on the timeline

  navigation
      |
      |--- TTFB ---> first byte arrives
      |
      |------------ LCP -----------> biggest thing painted
      |                              (hero image, headline)
      |
      |    [CLS accumulates across the whole visit]
      |     ^         ^                    ^
      |   font swap  ad slot inserted   image with no
      |                                 height attribute
      |
      user taps  ------ INP ------> next frame the
                                    user can see

LCP is a moment. INP is a response time, sampled across the visit and reported roughly as the worst of them. CLS is a running total for the entire time the page is open, which is why a shift caused by lazy content four screens down still counts.

Why Lighthouse and Search Console disagree

This confuses everyone once, so here it is plainly. There are two kinds of measurement and they answer different questions.

  • Lab data (Lighthouse, the Chrome DevTools panel) is one synthetic load on a simulated device. Reproducible, great for debugging, and not representative of anyone.
  • Field data (the Chrome User Experience Report, which feeds Search Console and PageSpeed Insights) is aggregated from real Chrome users who opted into reporting, over a rolling 28-day window.

The critical consequence: Lighthouse cannot measure INP. INP requires a human to interact with the page, and a synthetic run does not click anything. Lighthouse reports Total Blocking Time instead, which correlates with INP but is not the same number. If you are chasing an INP problem with Lighthouse alone, you are reading a proxy and hoping.

The 28-day rolling window has a practical implication too: ship a genuine fix today and the field number will crawl toward the truth over the following month. It is not broken, it is averaging.

Measuring it yourself

The browser exposes all of this through PerformanceObserver. LCP is straightforward:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Each entry is a new, larger candidate.
    // The last one before the first interaction wins.
    console.log('LCP candidate', entry.startTime, entry.element);
  }
}).observe({ type: 'largest-contentful-paint', buffered: true });

entry.element is the genuinely useful part: it hands you the DOM node responsible. Nine times out of ten it is a hero image, and now you know exactly which one.

Layout shifts are just as accessible:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // Shifts within 500ms of a real interaction are
    // expected, not penalized.
    if (entry.hadRecentInput) continue;
    console.log('shift', entry.value, entry.sources);
  }
}).observe({ type: 'layout-shift', buffered: true });

One honesty note: summing those value numbers does not give you CLS. The real metric takes the worst "session window" — a burst of shifts, capped at 5 seconds, broken by a 1 second gap — rather than the lifetime total. Reimplementing that correctly is fiddly, and there is no reason to. Google maintains the web-vitals library, which computes all three exactly the way Chrome reports them, INP included. Use it for anything you plan to act on. Use the raw observers above for poking around in the console, where entry.sources pointing at the offending element is worth more than a precise score.

What actually moves each number

LCP is usually server time plus one image. In that order:

  • Get TTFB down. Cache the HTML if you can, and check your database queries if you cannot. LCP can never be faster than your first byte.
  • Never lazy-load the LCP image. loading="lazy" on your hero is a self-inflicted wound; it deliberately delays the one image the metric is timing.
  • Add fetchpriority="high" to it so the browser stops treating it as equal to the footer icons.
  • Serve it at the size it renders, in a modern format. A 2400px-wide JPEG in a 800px slot is pure waste.
  • Get render-blocking CSS and synchronous scripts out of the head.

INP is almost always long tasks on the main thread. The browser cannot paint while JavaScript is running, so a 300 ms task means up to 300 ms of unresponsiveness for anything the user does during it. Break the work up and hand control back:

// Instead of one long loop that blocks painting:
async function processAll(items) {
  for (const item of items) {
    doWork(item);
    // Yield so the browser can paint and handle input.
    await new Promise(r => setTimeout(r, 0));
  }
}

Also: do less on every keystroke, debounce anything that touches layout, and be suspicious of third-party tags. A chat widget or tag manager running on the main thread is a very common cause of a bad INP that has nothing to do with your code.

CLS is reserved space, and it is the most fixable of the three:

  • Put width and height on every <img>. Modern browsers use them to compute an aspect ratio and hold the space before the file arrives. This one change fixes most CLS problems outright.
  • Give ad slots, embeds, and skeleton states a fixed minimum height.
  • Handle web fonts. A fallback with different metrics swapping to your real font reflows text; font-display: optional, or matching the fallback metrics with size-adjust, keeps it still.
  • Never insert content above what the user is already looking at. Cookie banners and promo bars that push the page down are textbook CLS.

How much does this matter for ranking?

Honest answer: it is a real signal and a small one. Core Web Vitals feed Google's page experience signals, and Google has been consistent that relevance and quality of content dominate. A fast page about nothing does not outrank a slow page that answers the question.

Which is fine, because ranking is the weaker argument anyway. These three metrics measure whether your site is unpleasant to use on a mid-range phone on a bad connection, which is most of the web. Fixing them makes the product better for the people already using it, and the search engines noticing is a bonus.

Start with LCP, because it is the most visible and usually the easiest win. If your server response is the bottleneck, caching headers are the cheapest lever you have — our .htaccess generator can assemble the cache and compression rules for an Apache host without a documentation detour.

More reading