Articles › Web and apps

Core Web Vitals in 2026: LCP, INP and CLS, how to measure and fix each

The three numbers Google uses for speed and stability: 2.5 s, 200 ms and 0.1 at the 75th percentile. Field vs lab data, finding the culprit, and fixes that work.

Intermediate 9 min read October 22, 2026

What you will learn
  • Passing means LCP ≤ 2.5 s, INP ≤ 200 ms and CLS ≤ 0.1 at the 75th percentile, mobile and desktop judged separately
  • INP replaced FID on 12 March 2024 and covers every click, tap and key press, not just the first
  • Google judges field data (CrUX, 28 days); Lighthouse helps you find causes but is not the score that counts
  • Averages lie; fix the slow tail of mobile users, not your own fast machine

Core Web Vitals are three numbers Google uses to describe how a page feels to real users: does it load fast (LCP), respond quickly (INP), and stay still (CLS). They feed into the page experience signals in Search, but the better reason to care is that they match what people feel on a phone: waiting, tapping with no response, or a button jumping away just as they press it.

Facts checked against web.dev, Chrome for Developers and PageSpeed Insights documentation as of October 2026; code checked against the web-vitals 6.2.3 source.

The three metrics and thresholds, as of October 2026

MetricMeasuresGoodNeeds improvementPoor
LCP (Largest Contentful Paint)when the largest image or text block in the first view finishes rendering≤ 2.5 s2.5-4 s> 4 s
INP (Interaction to Next Paint)from a click/tap/key press to the next frame showing the result≤ 200 ms200-500 ms> 500 ms
CLS (Cumulative Layout Shift)how much content moves without the user asking≤ 0.10.1-0.25> 0.25

A page passes when all three are good at the 75th percentile of page loads, with mobile and desktop judged separately. Three out of four visits must be good.

INP replaced FID on 12 March 2024. FID only measured the delay before the first interaction’s handler started. INP watches every click, tap and key press during the visit and reports the worst (on pages with lots of interactions, one of the highest is ignored for every 50, so a single hiccup does not dominate). Scrolling, hovering and zooming are not counted.

One interaction splits into three phases:

  1. Input delay: waiting for the event handler to start (the main thread is busy with something else)
  2. Processing duration: the handler running
  3. Presentation delay: from the handler finishing to the browser painting the next frame

Field data vs lab data

This is where teams get confused most often.

FieldLab
SourceCrUX: real Chrome users who opted in to usage statisticsLighthouse on an emulated device
Time spanprevious 28 days, updated dailythe moment you click run
Has INP?yesno, nobody clicks; TBT (Total Blocking Time) shows main-thread blocking instead
Google judges on ityesno
Use it toknow whether there is a real problem, and wherefind the cause, test fixes before deploying

CrUX limits worth knowing: it only counts Chrome on desktop and Android, from users who opted in to usage statistics and sync their history. Chrome on iOS, Safari, Firefox and Edge do not contribute. A page also needs enough traffic to cross a threshold Google does not publish, so small sites often see origin-level data only, or nothing.

A Lighthouse 100 does not mean you pass. What counts is field data at p75, on other people’s phones.

Worked example: the average passes, p75 fails

Say you collected 20 mobile LCP values (ms), sorted:

1200 1300 1400 1500 1600 1700 1800 1900 2000 2100
2200 2300 2400 2500 2600 3100 3400 3900 4600 5200
  1. Mean: sum = 16,500 + 12,000 + 20,200 = 48,700, divided by 20 = 2,435 ms. Looks fine
  2. Median: the 10th and 11th values, 2,100 and 2,200, give 2,150 ms. Also fine
  3. p75 (simple nearest-rank): 0.75 × 20 = the 15th value = 2,600 ms. Fails

Fourteen values are ≤ 2,500; you need 15 of 20 (75%) to pass, so at least one load from the slow tail has to come down. Those top five are people on slow phones and slow networks; making your own machine faster does nothing for them. (CrUX computes p75 from histograms, not exactly like this, but the principle is the same.)

Now split that 2,600 ms load into the four sub-parts web.dev recommends (illustrative numbers):

Sub-partTimeweb.dev’s suggested share
TTFB (waiting for the first HTML byte)600 ms~40%
Resource load delay (before the image request starts)900 ms< 10%
Resource load duration (downloading the image)900 ms~40%
Element render delay (loaded, waiting to paint)200 ms< 10%

Load delay is 35% when it should be close to zero: the browser found the image late. If a preload brings it to 150 ms: 600 + 150 + 900 + 200 = 1,850 ms. A pass, without shaving a single byte off the image.

TTFB600 msLoad delay900 msLoad duration900 msRender delay200 msLCP after preload1,850 mshypothetical example, LCP was 2,600 ms
Load delay should be near zero; one preload brings LCP to 1,850 ms

Finding the culprit: PageSpeed Insights, CrUX, web-vitals

  1. PageSpeed Insights (pagespeed.web.dev): the top section is CrUX field data, the bottom is Lighthouse. Read the top to see what is red, use the bottom to dig into causes
  2. Search Console > Core Web Vitals groups URLs with the same problem, which beats checking pages one by one
  3. Collect your own with the web-vitals library: you get data from every browser that supports the underlying APIs (not just Chrome), and it tells you which element is to blame
npm install web-vitals
// The attribution build tells you which element caused the problem
import { onCLS, onINP, onLCP } from 'web-vitals/attribution';

function send(metric) {
  const a = metric.attribution;
  const body = JSON.stringify({
    name: metric.name,          // 'LCP' | 'INP' | 'CLS'
    value: metric.value,
    rating: metric.rating,      // 'good' | 'needs-improvement' | 'poor'
    page: location.pathname,
    target: a.target ?? a.interactionTarget ?? a.largestShiftTarget,
    // the breakdown says why it was slow
    lcp: metric.name === 'LCP' && [a.timeToFirstByte, a.resourceLoadDelay,
                                   a.resourceLoadDuration, a.elementRenderDelay],
    inp: metric.name === 'INP' && [a.inputDelay, a.processingDuration, a.presentationDelay],
  });
  (navigator.sendBeacon && navigator.sendBeacon('/vitals', body)) ||
    fetch('/vitals', { body, method: 'POST', keepalive: true });
}

onLCP(send);
onINP(send);
onCLS(send);

On the server, store rows and query p75 per page per day. If you already run SQLite, one table is enough. target is a CSS selector such as main > img.hero, which tells you exactly what to fix.

Fixing LCP: let the browser find the image early and load it first

  • Put the LCP image in the HTML as an <img>. If it comes from a CSS background or JavaScript, the browser discovers it late; if you cannot avoid that, preload it
  • fetchpriority="high" on the LCP image, and only on one or two images; marking everything high means nothing is (supported in Chrome/Edge 101+, Firefox 132+, Safari 17.2+)
  • Never loading="lazy" on the LCP image. Lazy loading waits until layout confirms the image is in the viewport, which adds load delay directly
  • Cut TTFB. Slow HTML eats into LCP every time; cache pages, or keep heavy work off the hot path
  • Size images for the screen to cut load duration; see responsive images
<!-- Image in the HTML: state its priority directly -->
<img src="/hero.webp" width="1200" height="675" alt="..." fetchpriority="high">

<!-- Image in CSS/JS: preload it in <head> so the HTML reveals it -->
<link rel="preload" as="image" href="/hero.webp" type="image/webp" fetchpriority="high">

Fixing INP: keep the main thread free

Any task that holds the main thread longer than 50 ms is a long task, and the browser cannot respond to input while it runs. The rule: do the visible part first, then yield so the browser can paint, and do the rest afterwards.

function yieldToMain() {
  if (globalThis.scheduler?.yield) return scheduler.yield();
  return new Promise((resolve) => setTimeout(resolve, 0));
}

button.addEventListener('click', async () => {
  showSpinner();          // the user sees this immediately
  updateCart();
  await yieldToMain();    // let the browser paint first
  saveToServer();         // work nobody needs to see
  sendAnalytics();
});

As of October 2026, scheduler.yield() ships in Chrome/Edge 129+ and Firefox 142+, but not in Safari, so always keep the setTimeout fallback shown above.

Example (illustrative): a click with 40 ms input delay + 320 ms handler + 30 ms paint = 390 ms (needs improvement). Yield right after the UI update so only 60 ms runs before it: 40 + 60 + 30 = 130 ms, a pass. The same work still gets done; it just stops blocking the first frame.

Other common culprits: third-party scripts (chat widgets, analytics, ads) running while the user taps, which raises input delay; and very large DOMs, which raise presentation delay.

Fixing CLS: reserve the space

CLS groups shifts into session windows (gaps under 1 second, at most 5 seconds per window) and takes the worst window. Each shift scores impact fraction × distance fraction. An element covering 75% of the viewport that moves down 25% of the viewport height scores 0.75 × 0.25 = 0.1875. One shift like that and you fail.

  • Give every image and video width and height. The browser computes the aspect ratio and reserves space before the file arrives
  • Reserve space for ads, embeds and banners with min-height or aspect-ratio
  • Do not insert content above what is already showing, e.g. a cookie bar that pushes the page down; overlay it instead
  • Shifts within 500 ms of a click, tap or key press do not count (a menu expanding after a tap is fine). Scrolls, drags and pinch-zoom are not input, so shifts after them still count

Common mistakes

  • Trusting Lighthouse on a dev machine. Fast CPU, fast network, no extensions: nothing like your users
  • loading="lazy" on every image site-wide, hero included. LCP gets worse immediately
  • fetchpriority="high" on every image. Then none is more important than another
  • Watching the average instead of p75. In the example, a 2,435 ms average hides five people waiting more than 3 seconds
  • Checking CrUX the day after a fix. It is a 28-day window; expect about four weeks before it fully reflects the change, and watch your own web-vitals data meanwhile
  • Calling scheduler.yield() with no fallback. Safari will throw

The order that works: field data says which page is red → attribution says which element → the lab is where you try fixes → field data confirms.

Sources

FAQ

Lighthouse gives me 100. Why does Search Console say the page fails?

Lighthouse is lab data, one run on an emulated device with nobody clicking anything. Search Console uses real Chrome users over the last 28 days at the 75th percentile. Slow phones, slow networks and heavy button handlers never show up in the lab, but they do in the field.

My site is small and has no CrUX data. What now?

CrUX has an undisclosed popularity threshold, so small sites often get origin-level data or none. Collect your own with the web-vitals library, send it to your own endpoint, and compute p75 yourself.

Do I still need to care about FID?

No. INP replaced it in March 2024, Search Console dropped FID that same day, and the current web-vitals library no longer exports onFID.

Commands and settings were tested on sample systems. Try them on a test machine before production.