Overview

A page passes Core Web Vitals when, at the 75th percentile of real users, LCP is 2.5 s or less, INP is 200 ms or less, and CLS is 0.1 or less. INP replaced First Input Delay in 2024. Google uses these as one page-experience signal, and it does not override relevance. This guide gets a baseline from lab and field data, fixes each metric, and re-measures. Thresholds and rationale: core-web-vitals.

Prerequisites

  • A deployed URL. Field data (CrUX) exists only for production traffic and covers a rolling 28 days.
  • Node 22.19 or newer (the Lighthouse 13 engines floor) and Chrome for the Lighthouse CLI.
  • A deploy loop short enough to test one change at a time.

Steps

1. Measure the baseline

Lab data is fast and repeatable; field data is what the rankings signal is based on. Use both.

npm install -g lighthouse
lighthouse https://example.com --only-categories=performance \
  --output=json --output-path=./lh.json          # mobile by default; add --preset=desktop
node -e "const a=require('./lh.json').audits;
console.log('LCP',a['largest-contentful-paint'].displayValue,
'TBT',a['total-blocking-time'].displayValue,'CLS',a['cumulative-layout-shift'].displayValue)"

A navigation run has no user input, so Lighthouse reports Total Blocking Time (TBT) as the lab proxy for INP. For field data, open Search Console’s Core Web Vitals report, or query the CrUX API:

curl -s -X POST "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_KEY" \
  -H 'Content-Type: application/json' -d '{"url":"https://example.com/","formFactor":"PHONE"}' \
  | jq '.record.metrics | map_values(.percentiles.p75)'

To see per-page, per-deploy field values, add the web-vitals library:

import { onLCP, onINP, onCLS } from "web-vitals";
const send = (m: { name: string; value: number }) => navigator.sendBeacon("/analytics", JSON.stringify(m));
onLCP(send); onINP(send); onCLS(send);

2. Fix LCP: serve the hero element early

Lighthouse names the LCP element, usually the hero image or the H1.

<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<img src="/hero.avif" width="1200" height="630" fetchpriority="high" alt="...">
  • Never loading="lazy" on the LCP image. Format and sizing: optimize-images-for-web.
  • Render the hero in server HTML, not a client-only tree.
  • Self-host fonts and set font-display: swap; inline critical CSS.
  • Cache the HTML at a CDN to cut time to first byte (nextjs-caching for Next.js).

3. Fix CLS: reserve space before content arrives

Give every <img> and <iframe> width and height (or CSS aspect-ratio), give ad and embed slots a min-height, and never insert content above existing content. Animate transform and opacity only (css-animations). Match fallback font metrics with size-adjust, ascent-override, and descent-override in @font-face.

4. Fix INP: break up long tasks

INP is the slowest interaction in a visit, not the first. Yield between chunks of work, with a fallback where scheduler.yield() is missing.

const yieldToMain = () =>
  "scheduler" in globalThis && "yield" in (globalThis as any).scheduler
    ? (globalThis as any).scheduler.yield()
    : new Promise((r) => setTimeout(r, 0));
 
for (const item of items) { heavyWork(item); await yieldToMain(); }

Load third-party scripts with async or defer, ideally after first interaction. Use content-visibility: auto on off-screen sections. Hydrate less: partial hydration and server rendering cut main-thread work, but measure rather than assume. In DevTools, tasks over 50 ms show as long tasks in the Performance panel.

5. Re-measure after each change

Deploy one fix at a time and rerun Lighthouse. Field data takes up to 28 days to reflect a change, so do not wait on it inside the fix loop.

Verify it worked

node -e "const r=require('./lh.json');console.log(r.categories.performance.score*100)"
  • Lighthouse performance is above 90 (a house target, not a Google threshold), LCP under 2500 ms, CLS under 0.1, TBT low.
  • CrUX p75 values meet all three thresholds.
  • Search Console’s Core Web Vitals report moves to Good within about 28 days.

Common errors

  • Lab passes, field fails. Lab runs use a controlled network and CPU; real users on slower phones do not. Check field data by device.
  • Lighthouse scores vary run to run. Average three runs, or use --throttling-method=provided in CI.
  • CLS is zero in Lighthouse but high in CrUX. Lighthouse does not scroll or wait for ads; measure with web-vitals in production.
  • CrUX shows “not enough data”. The page or origin is below the traffic threshold; rely on Lighthouse.

FAQ

What are the passing thresholds for Core Web Vitals?

At the 75th percentile of real users, LCP is 2.5 s or less, INP is 200 ms or less, and CLS is 0.1 or less.

Why does Lighthouse not report INP?

INP needs real interactions and a navigation run has none, so Lighthouse reports Total Blocking Time as the lab proxy; measure INP in the field with web-vitals.

How long does a Core Web Vitals fix take to show in Search Console?

Up to about 28 days, because CrUX field data covers a rolling 28 days; rerun Lighthouse after each change instead of waiting on field data.