../

Core Web Vitals

Core Web Vitals are Google's three user-centered page experience metrics: loading (LCP), responsiveness (INP) and visual stability (CLS). They are measured on real visits in the field and judged at the 75th percentile. This sheet covers what each one measures, its parts, the usual fixes, and how to collect it. Tools are on Measuring & profiling.

The three metrics

good needs improvement poor LCP INP CLS 2.5 s 4.0 s 200 ms 500 ms 0.1 0.25
Thresholds, judged at the 75th percentile of page loads
MetricMeasuresGoodNeeds improvementPoor
LCP Largest Contentful Paintwhen the main content appeared≤ 2.5 s≤ 4.0 s> 4.0 s
INP Interaction to Next Painthow fast the page answers input≤ 200 ms≤ 500 ms> 500 ms
CLS Cumulative Layout Shifthow much the layout jumped≤ 0.1≤ 0.25> 0.25
  • p75: a page passes when 75% of its visits meet "good", assessed separately for mobile and desktop.
  • Field data: the official numbers come from real Chrome users (CrUX) over a rolling 28 days. Lab tools can't give you field INP: a Lighthouse page load has no interactions (TBT is its proxy), and DevTools live metrics and Lighthouse timespan mode only measure your own clicks.
  • Browser support: LCP and INP (Event Timing) are measurable in every engine since Safari 26.2 (December 2025; MDN marks both Baseline 2025). CLS's Layout Instability API is still Chromium-only. Only Chrome feeds CrUX; your own RUM sees the rest.
  • INP replaced FID (First Input Delay) as a Core Web Vital in 2024 (Google switched on March 12). FID only measured the delay of the first input; it is retired.

LCP: Largest Contentful Paint

The render time of the largest image, text block or video poster visible in the viewport, relative to the start of navigation.

Counts as a candidateDoesn't count
<img>, <image> in SVG, a <video> poster imageelements with opacity: 0 or offscreen
an element with a CSS background-image: url()full-viewport backgrounds judged as page background
block-level elements containing textlow-entropy placeholder images
content painted after the user scrolls, taps or types

The browser reports a new candidate each time a bigger element paints, and stops at the first user interaction. The last candidate is the LCP.

TTFB resource load duration load delay render delay ~40% <10% ~40% <10% navigation first byte download starts downloaded LCP (painted) text LCP has no resource: it is TTFB + render delay
LCP's four subparts: shrink the two delays first
SubpartFrom → toTarget shareTypical fixes
TTFBnavigation → first byte of HTML~40%CDN, caching HTML at the edge, fewer redirects, faster server work
Resource load delayTTFB → LCP resource starts loading< 10%image in the HTML (not JS/CSS), fetchpriority="high", preload, 103 Early Hints, no loading="lazy" on it
Resource load durationstart → end of the download~40%smaller image (AVIF/WebP, right size via srcset), CDN, less bandwidth contention
Element render delayresource loaded → painted< 10%less render-blocking CSS/JS, no client-side-only rendering, no A/B-test hiding snippets

Text LCP has no resource: its time is TTFB + render delay, often caused by web fonts blocking text (font-display) or by JS-rendered content. Image fixes in depth: CSS, images & fonts.

INP: Interaction to Next Paint

The time from a user's click, tap or key press to the next frame painted after the page handles it. Hover and scroll don't count. INP is (close to) the page's worst interaction: the slowest one, ignoring one outlier for every 50 interactions, so pages with lots of input aren't punished for a single hiccup.

tap next paint main another task event handlers render frame input delay processing duration presentation delay INP = tap → next paint
INP's three phases
PhaseCaused byFixes
Input delayother tasks already running: hydration, third-party scripts, timersbreak up long tasks; defer non-critical JS; less work at startup
Processing durationyour event handlers (all listeners for the event)do the visible update first, then yield and do the rest; debounce; move work to a worker
Presentation delaystyle, layout and paint of the resulting framesmaller DOM, fewer elements restyled, content-visibility, avoid layout thrashing
Paint first, then do the rest
import { yieldToMain } from "./yield";
 
button.addEventListener("click", async () => {
  button.classList.add("is-loading"); // visible update
  await yieldToMain(); // let input and rendering in
  saveDraft(); // heavy work in a later task
});

yieldToMain() is scheduler.yield() with a setTimeout fallback; it's on JavaScript & the main thread.

React renders and commits updates from a click or key press synchronously at the end of the event, so that JavaScript counts as processing duration; the browser's style, layout and paint of the resulting DOM is presentation delay. Mark non-urgent updates with startTransition so input stays responsive (Rendering strategies).

CLS: Cumulative Layout Shift

Each unexpected movement of visible content gets a layout shift score:

score=impact fraction×distance fraction\text{score} = \text{impact fraction} \times \text{distance fraction}
  • Impact fraction: the share of the viewport covered by the unstable elements, before and after the move.
  • Distance fraction: the largest distance moved, as a share of the viewport's biggest dimension.
  • Shifts are grouped into session windows: shifts less than 1 s apart, capped at 5 s per window. CLS is the worst window's total, not the sum over the page's lifetime.
  • Shifts within 500 ms of a click, tap or key press are expected and excluded (hadRecentInput). Scrolling and transform animations don't shift layout.
CauseFix
Images/videos without dimensionswidth + height attributes, or CSS aspect-ratio
Ads, embeds, iframes injected latereserve the slot: min-height on the container
Content inserted above existing content (banners, "new posts")insert below, or overlay, or on user action
Web fonts swapping and reflowing textsize-adjust / metric overrides on the fallback, font-display: optional
Animating top/height/marginanimate transform
Late CSS or client-rendered skeletons that don't matchserver-render the real layout; skeletons at final size
Page restored from bfcache vs full reloadbfcache-eligible pages restore without shifts

Supporting metrics

Useful for diagnosis; not Core Web Vitals themselves.

MetricMeasuresGoodWhere
TTFB Time to First Bytenavigation start → first byte of the response≤ 0.8 sfield + lab
FCP First Contentful Paintfirst text/image painted≤ 1.8 sfield + lab
TBT Total Blocking Timesum of (long task − 50 ms) between FCP and quiet≤ 200 mslab only, the lab proxy for INP
Long animation framesframes that took > 50 ms, with the scripts that caused them—field (LoAF API, Chromium)
Speed Indexhow fast the visible area fills in—lab

Soft navigations: in a single-page app, route changes don't reload the page, so classic LCP/CLS describe only the first load. Chrome 151 (July 2026) ships the Soft Navigations API (soft-navigation entries), and DevTools' live metrics report per soft navigation from Chrome 152. CrUX doesn't count them yet.

Where the numbers come from

SourceDataNotes
CrUX (Chrome UX Report)field, opted-in Chrome usersorigin + URL level, 28-day rolling; the official CWV data
PageSpeed InsightsCrUX field + a Lighthouse lab runone URL at a time; the "Core Web Vitals assessment" is field data
Search Console → Core Web VitalsCrUX, grouped by similar URLswhich templates fail
CrUX API / BigQuery / CrUX VisCrUXhistory and custom queries
DevTools Performance panelyour own session, live, + CrUX for the URLlocal LCP/INP/CLS as you click around
Your RUM (web-vitals → your endpoint)every browser that supports the APIs, every pagethe only source with your own segments (route, device, release)

Pages with too little traffic have no CrUX data. Collect your own RUM from the first release.

The web-vitals library

Google's ~2 KB library implements the metrics exactly as CrUX does (buffering, bfcache restores, prerendering, backgrounded tabs). Current major: v6 (2026). v5 removed onFID; v6 adds opt-in soft navigation reporting (reportSoftNavs) for single-page apps.

bun add web-vitals
vitals.ts
import { onCLS, onINP, onLCP } from "web-vitals";
import type { Metric } from "web-vitals";
 
function report(metric: Metric): void {
  // name, value, rating, delta, id, navigationType
  console.log(metric.name, metric.value, metric.rating);
}
 
onLCP(report);
onINP(report);
onCLS(report);
Metric fieldMeaning
name"LCP", "INP", "CLS", "FCP", "TTFB"
valuecurrent value (ms, or unitless for CLS)
rating"good", "needs-improvement", "poor"
deltachange since the last report (sum deltas when aggregating)
idunique per metric instance (new after a bfcache restore or soft navigation): dedupe with it
navigationType"navigate", "reload", "back-forward", "back-forward-cache", "prerender", "restore", "soft-navigation"
navigationURLthe URL the metric belongs to (v6; right for soft navigations, unlike location.href)
entriesthe underlying PerformanceEntry objects

Callbacks fire when the value is final (usually when the page is hidden), not on every change. Pass { reportAllChanges: true } while debugging.

Attribution build: same API from web-vitals/attribution, plus a metric.attribution object that says why (about 1.5 KB more):

MetricKey attribution fields
LCPtarget (selector), url, timeToFirstByte, resourceLoadDelay, resourceLoadDuration, elementRenderDelay
INPinteractionTarget, interactionType, inputDelay, processingDuration, presentationDelay, longAnimationFrameEntries
CLSlargestShiftTarget, largestShiftValue, largestShiftTime
FCP / TTFBtimeToFirstByte, firstByteToFCP; dnsDuration, connectionDuration, requestDuration, waitingDuration

Common mistakes

MistakeFix
Chasing the Lighthouse score instead of field datafix what CrUX / RUM says fails at p75
loading="lazy" on the hero imageeager + fetchpriority="high"
Measuring on a fast laptop on Wi-Fithrottle, or test on a mid-range Android
Averaging RUM valuesreport p75 (and p95), per device class
Summing value from repeated reportsuse delta, or keep the last value per id
Hiding the page until an A/B test script runsserver-side experiments; anti-flicker snippets add render delay
Treating INP as "first click"it tracks every interaction for the whole visit
A cookie banner that pushes content downoverlay it (position: fixed), don't insert it in flow
Ignoring soft (SPA) navigationsreportSoftNavs: true in RUM; CrUX still counts only hard loads

Recipes

Send vitals to your endpoint

Batch metrics and flush when the page is hidden; sendBeacon survives the page unloading.

rum.ts
import { onCLS, onINP, onLCP } from "web-vitals";
import type { Metric } from "web-vitals";
 
// the route pattern (/posts/[id]), not the raw URL, which
// can carry IDs or personal data
const route = document.documentElement.dataset.route ?? "?";
const queue = new Map<string, Metric>();
const add = (m: Metric) => queue.set(m.id, m);
 
function flush(): void {
  if (queue.size === 0) return;
  const body = JSON.stringify([...queue.values()].map(
    ({ name, value, rating, id }) =>
      ({ name, value, rating, id, route }),
  ));
  queue.clear();
  navigator.sendBeacon("/rum", body);
}
 
onLCP(add);
onINP(add);
onCLS(add);
addEventListener("visibilitychange", () => {
  if (document.visibilityState === "hidden") flush();
});
addEventListener("pagehide", flush); // fallback

Find the LCP element

Paste into the console, or ship it in a debug build: logs each LCP candidate as it appears.

lcp-debug.ts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const lcp = entry as LargestContentfulPaint;
    console.log("LCP candidate", lcp.startTime, lcp.element);
  }
}).observe({
  type: "largest-contentful-paint",
  buffered: true,
});

Log slow interactions with attribution

Which element was slow, and which phase to fix.

inp-debug.ts
import { onINP } from "web-vitals/attribution";
 
onINP(({ value, attribution }) => {
  const { interactionTarget, inputDelay,
    processingDuration, presentationDelay } = attribution;
  console.table({
    value, interactionTarget, inputDelay,
    processingDuration, presentationDelay,
  });
}, { reportAllChanges: true });

Reserve space for late content

An ad or embed slot that can't shift anything, and an image with its aspect ratio known up front.

no-shift.html
<div class="ad-slot" style="min-height: 250px">
  <!-- ad script fills this later -->
</div>
 
<img src="/hero.avif" width="1200" height="675"
     alt="Harbor at dawn" fetchpriority="high">

Log layout shifts and their sources

See which nodes moved, in the console, while you reproduce the jump.

cls-debug.ts
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    const shift = entry as LayoutShift;
    if (shift.hadRecentInput) continue;
    const nodes = shift.sources.map((s) => s.node);
    console.log("shift", shift.value, nodes);
  }
}).observe({ type: "layout-shift", buffered: true });

LayoutShift isn't in TypeScript's lib.dom yet, so declare the fields you read:

layout-shift.d.ts
interface LayoutShift extends PerformanceEntry {
  value: number;
  hadRecentInput: boolean;
  sources: { node: Node | null }[];
}

References