Skip to content
ZeroServer.tools
All guides

Core Web Vitals in 2026: LCP, INP, CLS and How to Actually Fix Them

July 7, 2026 · 10 min read

Core Web Vitals in 2026: LCP, INP, CLS and How to Actually Fix Them

Google's Core Web Vitals have been a ranking signal since 2021, but the metrics, thresholds, and tooling have all shifted since then. If your Lighthouse score is green while your Chrome User Experience Report (CrUX) data tells a different story, you are measuring the wrong thing. This guide breaks down what each metric actually captures in 2026, the most common root causes for failures, and the fixes that move the needle — with concrete numbers and code, not theory.

What Core Web Vitals Actually Measure

Core Web Vitals are three user-experience metrics Google uses as a page-experience ranking signal. In 2026 those three are:

  • LCP (Largest Contentful Paint): Time from navigation start until the largest image or text block in the viewport is fully rendered. Good: ≤ 2.5 s. Needs improvement: 2.5–4 s. Poor: > 4 s.
  • INP (Interaction to Next Paint): Time from a user interaction (click, tap, or key press) until the browser commits the next paint. Good: ≤ 200 ms. Needs improvement: 200–500 ms. Poor: > 500 ms.
  • CLS (Cumulative Layout Shift): Sum of all unexpected layout-shift scores over the page's lifetime, where each shift score = impact fraction × distance fraction. Good: ≤ 0.1. Needs improvement: 0.1–0.25. Poor: > 0.25.

INP replaced FID (First Input Delay) as a Core Web Vital in March 2024. FID only measured the delay before the browser started processing an event handler. INP measures the full input-to-paint roundtrip across every interaction during the session — a materially harder target.

Google evaluates all three at the 75th percentile of real-user sessions from CrUX. If 75% of your users pass, your page passes — even if 25% have terrible scores. That percentile threshold matters when you are triaging: optimizing the median is not enough.

// Instrument Core Web Vitals in your production bundle
import { onLCP, onINP, onCLS } from 'web-vitals';

onLCP(({ value, rating }) => sendToAnalytics('LCP', value, rating));
onINP(({ value, rating }) => sendToAnalytics('INP', value, rating));
onCLS(({ value, rating }) => sendToAnalytics('CLS', value, rating));

function sendToAnalytics(metric, value, rating) {
  // Replace with your analytics endpoint
  navigator.sendBeacon('/analytics', JSON.stringify({ metric, value, rating }));
}

Why Your LCP Is Failing

The LCP element is almost always a hero image, a large text heading, or a video poster frame. Failures cluster around a small set of root causes.

Late resource discovery. If the LCP image URL only appears after JavaScript executes — common in React and Next.js apps where hero components are rendered client-side — the browser cannot preload it during HTML parsing. The image starts downloading hundreds of milliseconds later than it should.

Render-blocking resources. A synchronous <script> or a <link rel="stylesheet"> that takes 300 ms to load delays LCP by at least that much. Run this in DevTools to count them:

document.querySelectorAll('link[rel=stylesheet], script:not([async]):not([defer])')

Slow TTFB. Time to First Byte above 600 ms is almost always fatal for LCP. A cold Lambda function, an uncached server-side render, or a slow database query eats your entire LCP budget before a single pixel renders.

Oversized or poorly compressed images. Serving a 2 MB JPEG when the viewport is 390 px wide wastes bandwidth and delays the LCP paint. For a standard 1200-px wide hero, target under 150 KB after compression.

The fastest diagnostic: open Chrome DevTools → Performance panel → record a load. Find the green LCP marker in the timeline. Hover it and read the four-part breakdown: TTFB, resource load delay, resource load duration, render delay. One phase almost always dominates.

How to Fix LCP

Preload the LCP image. Add a <link rel="preload"> in <head>, pointing at the final image URL. Include fetchpriority="high" on both the preload tag and the <img> element. This change alone typically cuts LCP by 300–800 ms on image-heavy pages.

<!-- In <head> — discovered before any JS executes -->
<link
  rel="preload"
  as="image"
  href="/hero-1200.webp"
  imagesrcset="/hero-600.webp 600w, /hero-1200.webp 1200w"
  imagesizes="100vw"
  fetchpriority="high"
>

<!-- In <body> -->
<img
  src="/hero-1200.webp"
  srcset="/hero-600.webp 600w, /hero-1200.webp 1200w"
  sizes="100vw"
  fetchpriority="high"
  width="1200"
  height="600"
  alt="Hero image"
>

Eliminate render-blocking CSS. Inline only above-fold critical CSS in a <style> tag. Load the rest asynchronously by toggling the media attribute:

<link rel="stylesheet" href="/main.css" media="print" onload="this.media='all'">

Improve TTFB. If TTFB exceeds 600 ms, no image optimization will fix LCP. Move pages to edge compute (Cloudflare Workers, Vercel Edge Functions), enable full-page caching with stale-while-revalidate, or pre-render static pages at build time. A 200 ms TTFB from a CDN edge node is a realistic target.

Serve modern image formats. WebP cuts file size by 25–35% over JPEG at equivalent quality. AVIF cuts it by 50%. Use <picture> with <source type="image/avif"> falling back to WebP, then JPEG.

INP: What It Measures and Why It Is Hard to Hit

INP measures your slowest interaction at the 98th percentile across the user's entire session. An interaction is any click, tap, or key press. The clock starts when the event fires and stops when the browser commits the next paint. The measurement includes three phases:

  • Input delay: Time the main thread is busy with other work before it can start handling the event.
  • Processing time: Time your event handlers take to execute.
  • Presentation delay: Time from when handlers finish to when the browser paints.

The 200 ms threshold sounds generous, but modern SPAs routinely blow past it. A React re-render triggered by a button click can consume 80–300 ms if the component tree is large. Add a 60 ms long task from an analytics script and you are at 360 ms before the browser even starts painting.

// Identify long tasks blocking input handling
const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.duration > 50) {
      console.warn(`Long task: ${entry.duration.toFixed(0)}ms — ${entry.name}`);
    }
  }
});
observer.observe({ type: 'longtask', buffered: true });

How to Fix INP

Break up long tasks. Any task over 50 ms can delay input processing. Use scheduler.yield() (Chrome 115+) or setTimeout(fn, 0) to yield between chunks of work:

async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    // Yield every 50 items so the browser can handle queued inputs
    if (i % 50 === 0) await scheduler.yield();
  }
}

Use startTransition in React 18+. Wrap state updates that drive expensive but non-urgent re-renders — search filtering, tab switching, accordion opens — in startTransition. React keeps the interaction responsive and batches the heavy work as a lower-priority task:

import { startTransition } from 'react';

function handleSearch(query) {
  // Input updates immediately; filtering deferred
  setQuery(query);
  startTransition(() => {
    setResults(bigList.filter(item => item.title.includes(query)));
  });
}

Audit third-party scripts. Tag managers, chat widgets, and ad SDKs are the leading cause of INP failures in field data. Load them with async at minimum. For scripts that are never needed on initial interaction, use requestIdleCallback or Partytown to move them off the main thread entirely.

Reduce JavaScript bundle size. Use dynamic import() to lazy-load routes and heavy components. Tree-shake dead exports. The Chrome DevTools Coverage tab shows which bytes of each JS file are never executed during page load — those are your prime candidates.

What Causes CLS

A layout shift happens when a rendered element moves after the browser has already painted it. The CLS score accumulates across the session. The most common causes:

Images and videos without explicit dimensions. Without width and height attributes, the browser allocates no space until the resource loads, then shunts everything below downward.

Web fonts causing FOUT. When a web font loads after the fallback has been painted, any metric difference between the two fonts triggers a text reflow. Even a 2 px ascender difference shifts every line of body text.

Dynamic content inserted above existing content. Cookie banners, newsletter pop-ins, and sticky ad slots that appear above the fold after load are textbook CLS offenders. Even a 50 px bar at the top can score 0.08 on its own.

CSS animations on layout properties. Animating top, left, width, or height forces layout recalculations on every frame and generates shift scores. Use transform instead — it runs on the GPU compositor thread and never triggers layout.

/* Wrong: animates layout properties, causes CLS */
@keyframes slideDown {
  from { margin-top: -60px; }
  to   { margin-top: 0; }
}

/* Right: composited transform, zero CLS */
@keyframes slideDown {
  from { transform: translateY(-60px); }
  to   { transform: translateY(0); }
}

How to Fix CLS

Always set width and height on images and videos. Modern browsers use these attributes to compute aspect ratio before the resource loads. If your CSS framework overrides dimensions, use aspect-ratio instead:

img, video, iframe {
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9; /* or whatever the asset's ratio is */
}

Use font-display: optional or override font metrics. font-display: optional tells the browser to use the web font only if it loads within a short initial window, suppressing FOUT entirely. Alternatively, use CSS @font-face descriptors to match fallback metrics to the web font:

@font-face {
  font-family: 'Inter';
  src: url('/fonts/inter.woff2') format('woff2');
  font-display: optional;
  /* or match metrics to eliminate reflow on swap: */
  /* ascent-override: 90%; descent-override: 22%; line-gap-override: 0%; */
}

Pre-allocate space for ads and dynamic content. Give ad slots a min-height matching the largest creative size you serve. Use contain: layout on ad iframes to isolate their layout context:

.ad-slot {
  min-height: 250px; /* Match your largest ad unit */
  contain: layout;
}

Audit with Layout Shift Regions. In Chrome DevTools → Rendering, enable "Layout Shift Regions." Every shift paints a blue overlay on the moving element. This is faster than reading the CLS waterfall in the Performance panel.

Measuring in Production vs. Lighthouse

Lighthouse produces lab data — a single synthetic run, throttled to simulate a mid-tier Android device on a 4G connection. CrUX produces field data — real user sessions aggregated over 28 days. The two can diverge sharply.

Signal Data Type Percentile Use For
Lighthouse Lab (synthetic) N/A Debugging, CI regressions
PageSpeed Insights (CrUX) Field (real users) p75 Google ranking signal
Search Console CWV Report Field (real users) p75 by URL group Prioritization across pages

A practical production stack:

  1. Instrument web-vitals in your app bundle and send values to your analytics platform (GA4 event, Datadog RUM, or a custom /analytics endpoint).
  2. Add Lighthouse CI to your GitHub Actions workflow to catch regressions at deploy time.
  3. Pull the CrUX API monthly, or check PageSpeed Insights, for your highest-traffic landing pages.

One common pitfall: UTM-tagged URLs fragment CrUX data. If your campaign URLs share a canonical page but differ in query strings, Google may group them separately and each variant may lack enough data to appear in the CWV report. Use the UTM Parameter Decoder to inspect and standardize UTM strings across campaigns, keeping traffic attributed to a single canonical URL.

Similarly, when your <meta> tags or Open Graph tags vary across landing page variants, social traffic can land on non-canonical URLs and inflate CLS or LCP scores for URLs you are not actively monitoring. The Meta Tag Generator and Open Graph Generator help you standardize tags across all page variants before launch.

# Lighthouse CI GitHub Actions gate
- name: Run Lighthouse CI
  run: |
    npx lhci autorun \
      --collect.url=${{ env.STAGING_URL }} \
      --assert.assertions.largest-contentful-paint='["error",{"maxNumericValue":2500}]' \
      --assert.assertions.cumulative-layout-shift='["error",{"maxNumericValue":0.1}]' \
      --assert.assertions.experimental-interaction-to-next-paint='["warn",{"maxNumericValue":200}]'

Conclusion

Core Web Vitals in 2026 are not a one-time audit checkbox — they are a continuous signal that reflects how real users experience your site across the full distribution of devices, networks, and session states. The highest-leverage fixes are consistent across most sites: cut TTFB below 600 ms, preload the LCP image with fetchpriority="high", break up long tasks to keep INP under 200 ms, and set explicit dimensions on every image to prevent CLS. Start with CrUX data from PageSpeed Insights or Search Console to identify which pages Google is actually scoring, then use Lighthouse in CI to prevent regressions from reaching production. A site that consistently passes all three vitals at the p75 threshold is not just better positioned in search — it is measurably faster for every user who lands on it.

Free & private — all tools run in your browser, nothing uploaded.

Recommended: IndieKit Ship your Next.js startup in days.affiliate