Danila (Dayfing)
Back to writing
2,563 words13 min

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

Start with field data, not a Lighthouse score. Find which metric fails at the 75th percentile, and on which device class, in Search Console or PageSpeed Insights, then add the web-vitals attribution build so real sessions name the element, interaction, or shift responsible. Fix LCP by its largest subpart, INP by the long task behind slow interactions, and CLS by reserving space for whatever moves. Lab tools reproduce problems and check fixes, but they do not decide whether you pass.

The three metrics and their thresholds

The Web Vitals overview on web.dev lists three stable Core Web Vitals:

  • Largest Contentful Paint (LCP), loading: good at 2.5 seconds or less, poor above 4 seconds.
  • Interaction to Next Paint (INP), responsiveness: good at 200 milliseconds or less, poor above 500 milliseconds.
  • Cumulative Layout Shift (CLS), visual stability: good at 0.1 or less, poor above 0.25.

Values in between "need improvement". Thresholds apply to the 75th percentile of page loads, split into mobile and desktop, and a page passes only when all three are good at that percentile. A healthy median can hide the quarter of visits on slow phones, and the assessment exists to catch them.

INP replaced First Input Delay in 2024. It observes clicks, taps, and key presses for the whole page lifetime and measures each until the next frame is painted. Hover, zoom, and scroll do not count, and one highest interaction is ignored per 50 interactions, so a single outlier does not define the score.

The web-vitals README lists onLCP() and onINP() as working in Chromium, Firefox, and Safari, and onCLS() as Chromium-only. MDN compatibility data shows Safari added the LCP and Event Timing APIs in version 26.2. Google's field dataset still comes only from Chrome users.

Field data versus lab data

Field data comes from real visits. The Chrome UX Report (CrUX) aggregates eligible Chrome users over a 28-day rolling window, at origin and URL level, for pages with enough public traffic. PageSpeed Insights shows it at the top of the report and falls back to origin data when a URL has too little. The Search Console Core Web Vitals report uses the same source, groups similar URLs, gives each group the status of its worst metric, and tracks mobile and desktop separately. A fix deployed today moves these numbers gradually over four weeks.

Lab data comes from one controlled load. Lighthouse loads the page on a simulated device and network. The DevTools Performance panel shows live local LCP, CLS, and INP, logs interactions with their phases and layout shifts with their scores, and can fetch CrUX data with suggested CPU and network throttling that matches your users.

They disagree for predictable reasons, documented in why lab and field data can differ:

  • LCP: lab runs use a cold cache, one viewport, and no personalization. Real users may have cached assets, a different LCP element, or an A/B variant, and bfcache restores count in the field.
  • INP: a Lighthouse navigation run has no interactions, so it reports Total Blocking Time instead. TBT helps diagnose blocking during load but misses a slow click later in a session.
  • CLS: lab runs see shifts during load. Field CLS covers the whole lifetime, including lazy content without dimensions and late ads.

Decide what is broken from field data, then reproduce it in the lab under realistic throttling. The CrUX API returns the 75th percentile directly and updates daily:

curl -s --request POST \
  "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
  --header 'Content-Type: application/json' \
  --data '{
    "origin": "https://example.com",
    "formFactor": "PHONE",
    "metrics": [
      "largest_contentful_paint",
      "interaction_to_next_paint",
      "cumulative_layout_shift",
      "largest_contentful_paint_image_time_to_first_byte",
      "largest_contentful_paint_image_resource_load_delay",
      "largest_contentful_paint_image_resource_load_duration",
      "largest_contentful_paint_image_element_render_delay"
    ]
  }'

The last four metrics are LCP subparts for loads where the LCP element is an image.

Collect real-user data with the attribution build

CrUX says that a metric fails, rarely why. The web-vitals library measures metrics the way Chrome does, and its attribution build adds the element, the timing breakdown, and the load state. Version 6 is the current major release. It can report soft navigations in single-page apps on Chromium 151 and later, but how CrUX will count them is still undecided.

npm install web-vitals

This module keeps the latest record per metric instance and sends a batch with navigator.sendBeacon() when the page becomes hidden, as the README recommends:

import {onCLS, onINP, onLCP} from 'web-vitals/attribution';

const queue = new Map();

function toRecord(metric) {
  const {name, value, rating, id, attribution} = metric;
  const record = {name, value, rating, id, page: metric.navigationURL ?? location.href};

  if (name === 'LCP') {
    record.target = attribution.target;
    record.ttfb = attribution.timeToFirstByte;
    record.loadDelay = attribution.resourceLoadDelay;
    record.loadDuration = attribution.resourceLoadDuration;
    record.renderDelay = attribution.elementRenderDelay;
  } else if (name === 'INP') {
    record.target = attribution.interactionTarget;
    record.inputDelay = attribution.inputDelay;
    record.processing = attribution.processingDuration;
    record.presentation = attribution.presentationDelay;
    record.loadState = attribution.loadState;
    record.script = attribution.longestScript?.entry.sourceURL;
  } else if (name === 'CLS') {
    record.target = attribution.largestShiftTarget;
    record.loadState = attribution.loadState;
  }
  return record;
}

function flush() {
  if (queue.size === 0) return;
  navigator.sendBeacon('/rum', JSON.stringify([...queue.values()]));
  queue.clear();
}

onLCP((metric) => queue.set(metric.id, toRecord(metric)));
onINP((metric) => queue.set(metric.id, toRecord(metric)));
onCLS((metric) => queue.set(metric.id, toRecord(metric)));

addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') flush();
});

CLS and INP can be reported more than once, so the collector keeps the last value per id. INP is absent when the user never interacts. Aggregate by page template and device class, then by target, to find the few elements that dominate p75. Your numbers will not match CrUX exactly: they include other browsers, and the library cannot see inside iframes. Sample busy sites, keep labels low in cardinality, and apply normal retention rules. The AI agent observability guide describes the same telemetry discipline.

LCP: find the slow subpart

The LCP optimization guide splits LCP into four sequential subparts:

  1. Time to first byte (TTFB): navigation start to the first byte of HTML.
  2. Resource load delay: TTFB to the start of the LCP resource request.
  3. Resource load duration: the fetch itself.
  4. Element render delay: resource ready to element painted.

For a text LCP element the middle two are zero. The guide suggests roughly 40 percent each for TTFB and load duration, and under 10 percent each for the delays. Against 2.5 seconds, that is about 1 second, 250 milliseconds, 1 second, and 250 milliseconds. A large load delay means late discovery. A large render delay means something blocked painting. Compressing the image fixes neither.

LCP fixes that move the number

Time to first byte

web.dev rates TTFB of 0.8 seconds or less as good and above 1.8 seconds as poor. It includes redirects, service worker startup, DNS, connection and TLS, and the request. Remove redirect chains, cache HTML at the CDN edge for anonymous traffic, and give fingerprinted assets long immutable cache lifetimes. The Nginx and Cloudflare static caching guide covers the headers and rules. If your origin is a small VPS, keep it lean with the Linux VPS hardening checklist and let the edge absorb traffic.

curl -s -o /dev/null \
  -w 'dns %{time_namelookup}\nconnect %{time_connect}\ntls %{time_appconnect}\nttfb %{time_starttransfer}\n' \
  https://example.com/

curl -sI https://example.com/ | grep -iE '^(cache-control|age|cf-cache-status):'

Resource load delay

The LCP image should start loading with the first resources of the page. Put it in the initial HTML as an <img>, never set loading="lazy" on it, and add fetchpriority="high", which MDN lists for Chromium, Firefox 132+, and Safari 17.2+. A hero rendered on the client is the usual culprit: the image cannot be requested until the bundle downloads, runs, and often fetches data. Server-render or prerender the hero. Preload an LCP image that comes from CSS.

<link rel="preload" as="image" href="/img/hero-bg.avif"
      type="image/avif" fetchpriority="high">

<picture>
  <source type="image/avif"
          srcset="/img/hero-800.avif 800w, /img/hero-1600.avif 1600w"
          sizes="(max-width: 800px) 100vw, 800px">
  <img src="/img/hero-800.jpg"
       srcset="/img/hero-800.jpg 800w, /img/hero-1600.jpg 1600w"
       sizes="(max-width: 800px) 100vw, 800px"
       width="800" height="450" alt="Product dashboard"
       fetchpriority="high">
</picture>

Use the <link> only for a CSS image and the <picture> for ordinary content.

Resource load duration

Send fewer bytes. Serve AVIF or WebP with a fallback, let srcset and sizes match the rendered slot, and serve from a CDN with cache lifetimes that let repeat visits skip the download. Keep few requests at high priority, since each competes with the LCP image.

Element render delay

If the image arrives early but paints late, look for large render-blocking stylesheets, synchronous scripts in <head>, and fonts with font-display: block or auto. Scripts that hide the page until they finish, as some experimentation tools do, add their runtime to LCP. Inline critical CSS, defer the rest, and render the hero without client-side JavaScript.

INP: find the interaction and its long task

An interaction has three phases. Input delay is the wait before handlers run, usually behind another task. Processing duration is the handlers themselves. Presentation delay runs from the end of the handlers to the next frame and includes style, layout, and paint.

Read them together. High input delay with loadState set to dom-interactive or dom-content-loaded means users tapped while the page was booting, typically during hydration or third-party scripts. High processing points at the handler. High presentation points at a large DOM or forced layout. In Chromium, longestScript from the Long Animation Frames API names the script that ran longest.

To reproduce, open the Performance panel, apply the suggested CPU throttling, and repeat the interaction on the RUM target. Any task over 50 milliseconds is a long task, and an interaction queued behind one inherits its remaining time.

INP fixes: less work per interaction

Paint the response first

Do the minimum that gives visible feedback, let the browser paint, then continue. Analytics and autosave rarely need to finish before the next frame. The long tasks guide shows this helper:

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

filterButton.addEventListener('click', async () => {
  filterButton.setAttribute('aria-busy', 'true');
  await yieldToMain();

  const rows = filterRows(allRows, currentQuery());
  renderRows(rows);
  filterButton.removeAttribute('aria-busy');

  await yieldToMain();
  sendAnalytics('filter', rows.length);
});

async function processInChunks(items, handleItem, budgetMs = 40) {
  let lastYield = performance.now();
  for (const item of items) {
    handleItem(item);
    if (performance.now() - lastYield > budgetMs) {
      await yieldToMain();
      lastYield = performance.now();
    }
  }
}

Yield with scheduler.yield where supported

scheduler.yield() resumes your code as a prioritized continuation, ahead of other queued tasks of similar priority. MDN marks it as limited availability: Chrome and Edge 129+, Firefox 142+, not Safari. Feature detection with a setTimeout() fallback is required. The fallback still splits the task but loses the priority. web.dev no longer recommends isInputPending() and advises yielding regardless. The chunking helper keeps each task under 40 milliseconds, so a tap mid-loop waits for one short task.

Hydration and islands

Full-page hydration runs the whole component tree during load, including static content. Interactions in that window get long input delays. Render static content as HTML, hydrate only interactive components, and delay those until idle or visible. In Astro, directives choose when each island hydrates:

---
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import PriceChart from '../components/PriceChart.jsx';
---
<SearchBox client:idle={{ timeout: 2000 }} />
<Comments client:visible={{ rootMargin: "200px" }} />
<PriceChart client:media="(min-width: 960px)" />

Components without a directive ship no JavaScript. React Server Components and partial hydration in other frameworks aim at the same result, but measure it rather than assume it. Audit third-party scripts too: remove unused tags, load the rest on idle, and watch longestScript for their URLs.

Presentation delay

Rendering cost grows with DOM size. Keep the DOM lean, use content-visibility: auto for long off-screen sections, and do not read offsetHeight right after writing styles, which forces synchronous layout. Move heavy parsing or sorting to a Web Worker.

CLS: reserve space and keep fonts stable

A layout shift scores impact fraction times distance fraction. Shifts less than 1 second apart form a session window of up to 5 seconds, and CLS is the largest window. Shifts within 500 milliseconds of a tap or key press are excluded. The CLS optimization guide lists the usual causes:

  • Media without dimensions: set width and height or aspect-ratio so the box is reserved.
  • Ads, embeds, and banners: reserve the slot with min-height, and never insert late content above what the user is reading.
  • Web fonts: use font-display: optional, or swap with a fallback tuned by size-adjust and the ascent, descent, and line-gap overrides, and preload critical fonts.
  • Animations: animate transform and opacity, not top, left, width, or height. Composited transforms do not count toward CLS.
  • Back/forward navigation: bfcache restores show a loaded page without shifts, so keep pages eligible.
.ad-slot {
  min-height: 250px;
}

@font-face {
  font-family: "Brand Sans Fallback";
  src: local("Arial");
  size-adjust: 104%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}

body {
  font-family: "Brand Sans", "Brand Sans Fallback", sans-serif;
}

.toast {
  transform: translateY(100%);
  transition: transform 200ms ease-out;
}

.toast.is-visible {
  transform: translateY(0);
}

The override percentages are placeholders. Derive them from your real font metrics and compare both fonts in the browser.

What to fix first

Start with the metric failing for the URL groups with the most traffic, on mobile unless desktop is your main audience. Within it, work on the subpart or phase with the largest p75 share. Ship cheap fixes first: fetchpriority, removing lazy loading from the hero, and image dimensions take minutes, while islands or server rendering need a planned migration.

Field signal Likely cause First fix Confirm with
LCP, TTFB dominates Uncached HTML, redirects Edge cache, no redirect chains curl timings
LCP, load delay dominates Late discovery, lazy hero, client rendering <img> in HTML, fetchpriority Network waterfall
LCP, load duration dominates Oversized image, old format AVIF or WebP, srcset, CDN lcpResourceEntry
LCP, render delay dominates Blocking CSS, scripts, or fonts Critical CSS, deferred scripts Performance trace
INP, input delay during load Hydration, third-party tags Islands, delayed hydration loadState
INP, long processing Heavy handler Paint first, yield, worker longestScript
INP, long presentation Large DOM, forced layout Smaller DOM, content-visibility Interactions table
CLS during load Media without size, font swap Dimensions, tuned fallback largestShiftTarget
CLS after load Late content, layout animations Reserved slots, transform Layout shifts tab

Verify the fix and prevent regressions

Confirm each change twice. In the lab, compare Performance traces before and after under field-based throttling and check that the targeted subpart shrank. In the field, watch p75 in your RUM for the affected template, which responds within days, and let CrUX follow over 28 days. Then use "Start tracking" in Search Console to begin its 28-day validation.

Keep a Lighthouse budget in CI to catch new render-blocking scripts, missing image dimensions, or jumps in Total Blocking Time. It cannot see real interactions, so also alert on field INP, and annotate deployments in the RUM dashboard.

Checklist

  • Read Search Console and PageSpeed Insights field data for mobile and desktop.
  • Note which metric fails at p75 for your highest-traffic URL groups.
  • Add the web-vitals attribution build and send beacons on visibilitychange.
  • Aggregate RUM by template, device class, and target.
  • Find the dominant LCP subpart before choosing a fix.
  • Put the hero in HTML with fetchpriority="high", no lazy loading, and correct srcset.
  • Cache HTML at the edge and keep TTFB at 0.8 seconds or less.
  • Split long handlers with scheduler.yield() and a setTimeout() fallback.
  • Hydrate only interactive islands and audit third-party scripts.
  • Reserve space for media, ads, and banners, and tune fallback fonts.
  • Verify fixes in a throttled trace, then in RUM, then in CrUX.

More