What Are Core Web Vitals: LCP, INP and CLS Thresholds Explained for Developers

Core Web Vitals are three Google metrics that measure how a real page feels to a real user: how fast the main content appears (LCP), how quickly the page responds to clicks and taps (INP), and how much the layout jumps around while it loads (CLS). They are collected from actual Chrome users, aggregated at the 75th percentile over a rolling 28-day window, and they feed both the Search Console report and Google’s page experience signals.

That definition is the easy part. The hard part, and the reason most teams stay stuck in the orange zone, is knowing which line of code is responsible for a failing score. This guide is written for developers: exact thresholds, how field data differs from lab data, and concrete examples of a slow LCP element, a blocking INP handler and a shifting CLS element.

The Three Core Web Vitals at a Glance

Metric What it measures Good Needs improvement Poor
LCP
Largest Contentful Paint
Loading: time until the largest text block or image in the viewport is painted ≤ 2.5 s 2.5 s to 4.0 s > 4.0 s
INP
Interaction to Next Paint
Responsiveness: latency from user input to the next frame painted, across the whole page visit ≤ 200 ms 200 ms to 500 ms > 500 ms
CLS
Cumulative Layout Shift
Visual stability: largest burst of unexpected layout shifts, unitless score ≤ 0.1 0.1 to 0.25 > 0.25

The rule everyone forgets: the 75th percentile

A page does not “pass” because your laptop loads it in 1.2 seconds. Google evaluates the 75th percentile of real user experiences, segmented by device class (mobile and desktop are scored separately). All three metrics must be in the “Good” bucket at p75 for the URL or origin to pass the Core Web Vitals assessment.

  • If 74% of your users get an LCP of 2.0 s and 26% get 6.0 s, your p75 is poor. Your slowest quarter of traffic decides your score.
  • URLs without enough traffic fall back to origin-level data, which means one bad template can drag down pages you never touched.
  • Field data is a rolling 28-day aggregate, so a fix deployed today will not show up in Search Console tomorrow.
web design

Field Data (CrUX) vs Lab Data (Lighthouse): Why Your Scores Disagree

This is the number one source of confusion in performance reviews. A Lighthouse score of 98 with a red Search Console report is not a bug, it is two different measurement systems.

Aspect Field data (CrUX) Lab data (Lighthouse)
Source Real opted-in Chrome users One synthetic load in a controlled environment
Used for search signals Yes No
INP available Yes, from real interactions No. Lighthouse reports Total Blocking Time as a proxy because nobody clicks during a lab run
CLS scope Full page lifespan, including scroll, lazy content, cookie banners and route changes Only shifts observed during the automated load, usually no scrolling
Network and CPU Everything from 5G flagships to throttled 3G on low-end Android Simulated slow 4G with 4x CPU slowdown by default
Caching Mix of cold, warm and back/forward cache navigations Cold load, empty cache
Latency of results 28-day rolling window Immediate
Best used for Deciding what is broken and for whom Diagnosing why and validating a fix before deploy

Classic mismatches and what they usually mean

  • Green Lighthouse, red field LCP: your test URL was cached at the CDN edge, or real users land on pages with personalized, uncached HTML. Check TTFB in the field breakdown.
  • Green Lighthouse, red field INP: expected. Lighthouse never interacts with your UI. Your filter, menu or add-to-cart handler is the culprit.
  • CLS 0 in the lab, 0.31 in the field: the shift happens after scroll (lazy loaded images, ad slots) or on a client-side route change in a SPA.
  • Field better than lab: most of your audience is on fast desktop connections, while Lighthouse mobile throttling is deliberately pessimistic.

LCP: Which Element Is Slow and Why

LCP marks the render time of the largest image or text block visible in the viewport. Candidates include <img>, an <image> inside <svg>, a poster on <video>, an element with a CSS background-image, and block-level text nodes.

Break LCP into its four subparts

  1. Time to First Byte: server and network. Target roughly 40% of your LCP budget.
  2. Resource load delay: the gap between TTFB and the browser actually starting to fetch the LCP resource. Target under 10%. This is where lazy loading and late discovery hurt.
  3. Resource load duration: downloading the image. Target roughly 40%.
  4. Element render delay: the gap between the resource arriving and it being painted. Target under 10%. Blocked by render-blocking CSS/JS or hydration.

Example of a slow LCP element

<!-- Hero markup that guarantees a poor LCP -->
<div class="hero">
  <!-- 1. Hidden from the preload scanner: injected by JS after hydration -->
  <div id="hero-slot"></div>
</div>

<script type="module">
  import { mountCarousel } from '/js/carousel.js'; // 180 KB, parsed late
  mountCarousel('#hero-slot', {
    // 2. lazy on an above-the-fold image: fetch starts after layout
    image: '<img src="/img/hero-3000px.jpg" loading="lazy">'
  });
</script>

Everything here is wrong at once: the image is not in the initial HTML so the preload scanner cannot see it, loading="lazy" pushes the request behind layout, the JPEG is oversized for mobile, and the paint waits on a module that has to download, parse and execute first. Load delay and render delay eat the whole budget before a single byte of the image arrives.

The corrected version

<!-- In <head>: discoverable immediately -->
<link rel="preconnect" href="https://cdn.example.com">
<link rel="preload" as="image" fetchpriority="high"
      href="https://cdn.example.com/hero-800.avif"
      imagesrcset="https://cdn.example.com/hero-800.avif 800w,
                   https://cdn.example.com/hero-1600.avif 1600w"
      imagesizes="100vw">

<!-- In the server-rendered HTML, not injected by JS -->
<img src="https://cdn.example.com/hero-800.avif"
     srcset="https://cdn.example.com/hero-800.avif 800w,
             https://cdn.example.com/hero-1600.avif 1600w"
     sizes="100vw"
     width="1600" height="900"
     fetchpriority="high" decoding="sync"
     alt="Product hero">

Code-level causes of a poor LCP

  • loading="lazy" or fetchpriority="low" on an above-the-fold image.
  • LCP image referenced only from a CSS background-image, so it is discovered after CSSOM construction.
  • Client-side rendered hero: the element does not exist until JS runs.
  • Render-blocking CSS bundles, synchronous third-party scripts in <head>, or fonts loaded with font-display: block when the LCP element is text.
  • Uncompressed or unsized images (a 3000 px JPEG served to a 390 px viewport).
  • Slow TTFB from uncached, database-heavy server rendering with no CDN or edge caching.
  • Multiple redirects on entry URLs (http to https to www to locale).
web design

INP: The Metric That Punishes Your Event Handlers

INP replaced First Input Delay as a Core Web Vital in March 2024 and it is far stricter. FID only measured input delay on the first interaction. INP measures the full latency of the whole interaction, from the moment the user taps until the browser paints the next frame, and it does this for every click, tap and key press on the page, then reports (roughly) the worst one. There is a practical rundown of it online.

The three phases of an interaction

  1. Input delay: the main thread is busy with something else (a long task, a third-party tag, hydration) when the user clicks.
  2. Processing duration: your event listeners running, plus any synchronous work they trigger.
  3. Presentation delay: style recalculation, layout, paint and compositing of the resulting frame.

Example of a blocking INP handler

// Poor INP: 500 ms+ of synchronous work before any pixel changes
filterBtn.addEventListener('click', () => {
  const results = products.filter(matchesAllFacets);   // ~120 ms on mid Android
  results.sort(byRelevanceScore);                      // ~90 ms
  grid.innerHTML = results.map(cardTemplate).join(''); // ~200 ms of DOM work
  window.dataLayer.push({ event: 'filter', results }); // sync tag fires here
  updateUrlState(results);                             // forced reflow
});

The user sees nothing for half a second. Everything runs in one task before the browser gets a chance to paint, so processing duration alone blows past the 200 ms threshold.

The corrected version

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

filterBtn.addEventListener('click', async () => {
  // 1. Paint immediate feedback in the very next frame
  filterBtn.setAttribute('aria-busy', 'true');
  grid.classList.add('is-loading');
  await yieldToMain();

  // 2. Heavy computation off the critical interaction path
  const results = await computeInWorker(products, activeFacets);
  await yieldToMain();

  // 3. Render in chunks, not one giant innerHTML
  await renderBatched(grid, results, 20);

  // 4. Non-urgent work last
  requestIdleCallback(() => {
    window.dataLayer.push({ event: 'filter', count: results.length });
    updateUrlState(activeFacets);
  });

  filterBtn.removeAttribute('aria-busy');
});

Code-level causes of a poor INP

  • Long synchronous loops, sorting or JSON parsing inside a click or keydown handler.
  • Large innerHTML replacements or full component re-renders that trigger a big style and layout pass.
  • Forced synchronous layout (reading offsetWidth or getBoundingClientRect right after writing styles) inside the handler.
  • Analytics, chat widgets, consent tools and A/B testing scripts that attach their own listeners to the same element.
  • Framework hydration still running when the user taps during load, inflating input delay.
  • Non-passive touchstart or wheel listeners doing real work.
  • setTimeout chains and animation work competing on the main thread.

Debug tip: use the Long Animation Frames API (LoAF) to attribute the slow frame to a specific script URL and function.

new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    if (entry.blockingDuration > 50) {
      console.log(entry.duration, entry.scripts.map(s => ({
        source: s.sourceURL,
        fn: s.sourceFunctionName,
        dur: s.duration
      })));
    }
  }
}).observe({ type: 'long-animation-frame', buffered: true });

CLS: Finding the Element That Moves

CLS is unitless. Each unexpected shift scores impact fraction x distance fraction, and Google reports the largest session window: a burst of shifts grouped with a maximum gap of 1 second between them and a maximum total window of 5 seconds. Shifts that happen within 500 ms of a user interaction are excluded, which is why opening an accordion does not count against you but an auto-injected banner does.

Example of a shifting CLS element

<!-- No intrinsic size: the browser reserves 0 px until the image loads -->
<img src="/img/product.jpg" alt="Product">

<!-- Ad or promo slot injected above the fold after the API responds -->
<div id="promo-bar"></div>
<script>
  fetch('/api/promo').then(r => r.json()).then(d => {
    // Pushes the entire page down by 90 px, one second after paint
    document.getElementById('promo-bar').innerHTML = renderPromo(d);
  });
</script>

<style>
  /* Fallback metrics differ wildly from the web font */
  @font-face { font-family: Brand; src: url(/f/brand.woff2); font-display: swap; }
</style>

The corrected version

<img src="/img/product.jpg" width="800" height="600" alt="Product">

<div id="promo-bar" style="min-height:90px"></div>

<style>
  img { height: auto; }          /* keeps the reserved aspect ratio responsive */
  .card-media { aspect-ratio: 4 / 3; }

  @font-face {
    font-family: Brand;
    src: url(/f/brand.woff2) format('woff2');
    font-display: optional;      /* no swap, no reflow */
  }
  @font-face {
    font-family: 'Brand Fallback';
    src: local('Arial');
    size-adjust: 104%;
    ascent-override: 92%;
  }
</style>

Code-level causes of a poor CLS

  • <img>, <video> or <iframe> without width and height attributes or an aspect-ratio.
  • Cookie banners, promo bars, newsletter popups and app install prompts inserted at the top of the DOM instead of overlaid.
  • Ad slots with variable creative sizes and no reserved container.
  • Web fonts causing FOUT with mismatched fallback metrics.
  • Content injected on scroll (infinite feeds, lazy sections) that pushes existing content down.
  • Animating top, left, height or margin instead of transform.
  • SPA route transitions that render a skeleton of a different height than the final view.

To catch the exact node in production, log shift sources:

new PerformanceObserver(list => {
  for (const entry of list.getEntries()) {
    if (entry.hadRecentInput) continue;
    entry.sources.forEach(s => console.log(entry.value, s.node));
  }
}).observe({ type: 'layout-shift', buffered: true });
web design

Quick Diagnostic Map

Symptom in the field Likely code cause First fix to try
High LCP load delay Image hidden from the preload scanner or lazy loaded Server-render the <img>, add fetchpriority="high", remove loading="lazy"
High LCP render delay Render-blocking CSS/JS or client-side hydration Inline critical CSS, defer non-critical JS, stream HTML
High TTFB Uncached dynamic rendering, redirect chains Edge caching, ISR/SSG, remove redirects
High INP input delay Long tasks during load, heavy third-party tags Code split, defer tags, yield during hydration
High INP processing Synchronous work in the handler Paint feedback first, then scheduler.yield() or a Web Worker
High INP presentation Huge DOM updates and layout thrash Batch DOM writes, virtualize lists, use content-visibility
CLS spike at load Unsized media, late banners, font swap Set dimensions, reserve space, use font-display: optional
CLS spike after scroll Lazy sections and ads without placeholders Fixed min-height containers and skeletons matching final size

How to Measure Core Web Vitals Properly

1. Start with field data

  • Search Console Core Web Vitals report: groups URLs by status and template, best for spotting which page types fail at scale.
  • PageSpeed Insights: shows CrUX field data at the top and a Lighthouse lab run underneath, on the same screen.
  • CrUX History API / BigQuery dataset: for trend lines and competitor benchmarking.
  • Your own RUM: the web-vitals JavaScript library with the attribution build gives you the LCP element selector, the INP target and the CLS source nodes for your actual users. This is the only way to segment by template, country, device and logged-in state.
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

const send = (metric) => {
  navigator.sendBeacon('/rum', JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    target: metric.attribution.element || metric.attribution.interactionTarget,
    path: location.pathname
  }));
};

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

2. Reproduce in the lab

  1. Open Chrome DevTools, Performance panel, and check the local metrics overlay. It shows your live LCP, INP and CLS while you interact.
  2. Throttle to 4x CPU slowdown and slow 4G to approximate a mid-range Android device.
  3. Interact with the exact control your RUM flagged, then read the interaction breakdown in the trace.
  4. Run Lighthouse for the loading metrics and treat Total Blocking Time as your INP early-warning signal.

3. Prevent regressions

  • Add Lighthouse CI to pull requests with budgets on LCP, TBT and CLS.
  • Set performance budgets per route, not per site.
  • Alert on p75 shifts in your RUM data instead of waiting 28 days for Search Console.
web design

Are Core Web Vitals Still Worth the Engineering Time?

Yes, with realistic expectations. Core Web Vitals remain part of Google’s page experience signals, but they are a tiebreaker, not a substitute for relevance and content quality. Moving from a poor to a good LCP will not outrank a better answer to the query. What it reliably does:

  • Removes a negative signal when you compete against pages with similar topical authority.
  • Improves crawl efficiency on large sites where slow responses limit crawl budget.
  • Directly affects conversion. Bounce and abandonment climb sharply as LCP crosses 4 seconds and as interactions feel unresponsive.
  • Gives you a shared, objective vocabulary between engineering, SEO and marketing teams.

FAQ

What is Core Web Vitals in simple words?

They are three scores from Google that describe how a page feels to a real visitor: how long the main content takes to show up (LCP), how quickly the page reacts when you tap or click (INP), and whether things jump around while it loads (CLS).

What is a good Core Web Vitals score?

LCP of 2.5 seconds or less, INP of 200 milliseconds or less, and CLS of 0.1 or less, measured at the 75th percentile of real user visits for both mobile and desktop. A fuller account is out there.

How do I pass the Core Web Vitals assessment?

All three metrics must be in the “Good” range at the 75th percentile in the CrUX field dataset. A single metric in “Needs improvement” makes the URL fail the assessment. Pages with insufficient traffic inherit the origin-level result.

Why does Lighthouse give me 100 while Search Console says my page is poor?

Lighthouse is a single simulated load on a controlled connection with no user interaction. Search Console uses 28 days of real Chrome user data across every device and network. Lighthouse also cannot measure INP at all, and it only sees layout shifts that occur during its automated load.

Does INP replace FID?

Yes. Interaction to Next Paint became the official responsiveness Core Web Vital in March 2024 and First Input Delay was retired. INP measures the full interaction latency across the entire page visit, not just the delay on the first input.

How long after a fix will my Core Web Vitals improve?

Field data uses a rolling 28-day window, so expect gradual movement starting within a few days and a fully refreshed score after about four weeks. Use your own RUM to confirm the fix immediately.

Do Core Web Vitals affect rankings?

They are a real but lightweight signal. Treat them as a competitive tiebreaker and, more importantly, as a conversion lever. Relevance and content quality still dominate.

Key Takeaways

  1. LCP ≤ 2.5 s, INP ≤ 200 ms, CLS ≤ 0.1, all at the 75th percentile of real users.
  2. Field data (CrUX) decides your status. Lab data (Lighthouse) helps you diagnose and validate.
  3. Poor LCP is almost always late discovery of the hero resource or render-blocking work, not raw bandwidth.
  4. Poor INP is almost always synchronous JavaScript in an event handler. Paint feedback first, yield, then do the heavy work.
  5. Poor CLS is almost always unreserved space. Set dimensions on everything, and overlay late content instead of injecting it inline.
  6. Instrument your own RUM with the attribution build so you know which element, which handler and which node to fix.

Fixing Core Web Vitals is rarely a rewrite. In most audits, three or four targeted changes to the hero image, one event handler and a couple of unsized containers move a template from red to green.