Performance & Core Web Vitals

A page can score 100 in a synthetic test and still feel broken to the person using it. The button that takes 400ms to respond, the headline that jumps down as an ad loads, the hero image that paints two seconds late on a mid-range phone over a flaky connection — none of that shows up if you only ever profile on a fast laptop plugged into fiber.

Core Web Vitals exist to close that gap. They are three numbers, each tied to a distinct moment of felt experience: how fast the main content shows up, how quickly the page answers when you tap it, and how much the layout lurches around under you. They are deliberately few, deliberately user-centric, and — this is the part people miss — meant to be measured on real devices in the field, not just in a lab.

This article is about what the three metrics actually measure, what makes each one bad, how to fix them, and how to wire up measurement that reflects your real users instead of your own machine.

The three metrics at a glance

Each vital has a “good” threshold and a “poor” threshold, with a middle band called needs improvement. A site “passes” when at least 75% of page loads hit the good bar — the 75th percentile, so your slower quarter of visits still counts.

LCPloading≤ 2.5s good≤ 4s> 4sINPresponsiveness≤ 200ms good≤ 500ms> 500msCLSstability≤ 0.1 good≤ 0.25> 0.25measured at the 75th percentile of real page loads
The three Core Web Vitals, what each one measures, and the good / needs-improvement / poor thresholds evaluated at the 75th percentile of real visits.

A quick note on maturity: all three metrics, the browser APIs behind them, and the web-vitals library are Baseline widely available. The one big recent shift is that INP replaced FID (First Input Delay) as a Core Web Vital in March 2024 — if you read older material that talks about FID, that guidance is out of date. FID only ever measured the delay before the first interaction could be processed; INP measures the full round trip of interactions across the whole page life.

LCP: how fast does the main thing show up?

Largest Contentful Paint marks the moment the biggest piece of content in the viewport finishes rendering. “Biggest” is measured by the area the element covers: usually a hero image, a video poster frame, a background image, or a large block of text. The browser keeps re-reporting a new LCP candidate as bigger elements paint, and finalizes the value at the first user interaction (or when the page is hidden).

The target is 2.5 seconds or less at the 75th percentile.

The useful way to think about LCP is not as one number but as four sequential phases. Fixing LCP means finding which phase is eating your budget.

TTFBresourceload delayresource loaddurationelementrender delaynavigationLCPtotal time to Largest Contentful Paint →
LCP breaks into four phases: time to first byte, the delay before the LCP resource starts loading, how long that resource takes to load, and the render delay after it arrives. The largest phase is where to spend your effort.

Common causes and fixes

Slow server response (TTFB). If the first byte is slow, everything downstream slides. Cache HTML at the edge, use a CDN, and avoid blocking the document on slow database calls. Nothing you do on the front end can hide a 1.5s TTFB.

Render-blocking resources. A stylesheet or synchronous script in the <head> stops the browser from painting anything until it downloads and runs. Inline the critical CSS, defer non-critical JS with defer or type="module", and load fonts without blocking.

The LCP image starts loading late. If your hero image is discovered only after the CSS parses (because it is a CSS background-image, or lazy-loaded, or injected by JS), its load delay balloons. Put it in the HTML as a real <img>, never lazy-load it, and hint the browser to fetch it early:

<img src="/hero.avif" fetchpriority="high" width="1200" height="675" alt="…" />

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

The image is simply too big. Serve modern formats (AVIF, WebP), size it to the actual display box, and use srcset so phones do not download desktop-sized art.

INP: how quickly does the page answer?

Interaction to Next Paint measures responsiveness. Every time the user taps, clicks, or presses a key, the browser measures the full latency from that input until the next frame is painted showing a visual response. INP reports (roughly) the worst such interaction across the entire visit. The target is 200ms or less.

This is the metric that punishes heavy JavaScript. To understand why, you have to remember that the browser runs your JS, layout, and painting on a single main thread — the same event loop that everything else queues through. While a long task is running, the thread is busy, and the user’s tap just waits.

An interaction’s latency splits into three parts:

input delaythread was busyprocessingyour event handlers runpresentationlayout + paintuser tapsnext paintInteraction to Next Paint →
INP is input delay plus processing time plus presentation delay. A long task already running when the user taps inflates the input delay before your handler even starts.

The long-task problem

Say a click handler does 300ms of synchronous work — parsing a big payload, sorting a list, updating a chart. During those 300ms the thread cannot paint, cannot process other input, cannot do anything. If the user clicks something else mid-task, that click’s input delay includes the leftover of the first task.

The fix is almost always the same idea: break the long task into chunks and yield to the browser between them, so the thread gets a chance to paint and handle pending input.

one long task — thread blockedtask (300ms)tapwaits ~200mschunked with yields — thread breathesyieldyieldyieldtap handled fast
One long task blocks the thread and pending input waits behind it. The same work split into chunks with yields lets the browser paint and respond between pieces, cutting the felt latency.

Yielding in practice

The modern tool for yielding is scheduler.yield(). It returns a promise; awaiting it hands control back to the browser and then resumes your function, and — unlike setTimeout(0) — your continuation keeps priority so it is not overtaken by unrelated tasks.

async function processAll(items) {
  for (const item of items) {
    doExpensiveWork(item);

    // Give the browser a chance to paint and handle input.
    if (navigator.scheduling?.isInputPending?.()) {
      await scheduler.yield();
    }
  }
}
function yieldToMain() {
  if ("scheduler" in globalThis && "yield" in scheduler) {
    return scheduler.yield();
  }
  return new Promise((resolve) => setTimeout(resolve, 0));
}

Beyond yielding, the other big INP levers are:

  • Defer non-urgent work. After you paint the visual response to a click, push analytics, logging, and prefetching into a requestIdleCallback or a post-paint setTimeout so they do not sit inside the interaction.
  • Move heavy compute off the main thread entirely with a web worker — parsing, image processing, and diffing large data structures do not need the UI thread.
  • Shrink what runs on input. A framework re-rendering a huge tree on every keystroke is a classic INP killer; debounce, virtualize long lists, and memoize.

CLS: does the layout hold still?

Cumulative Layout Shift measures visual stability. Every time a visible element moves without a user action causing it, the browser records a layout shift score: the fraction of the viewport affected multiplied by the distance things moved. CLS sums the largest cluster of these shifts (a “session window”). The target is 0.1 or less.

Everyone has felt bad CLS: you go to tap a link, an image finishes loading above it, the whole page jumps, and you tap an ad instead.

no space reservedimage loads inBuydimensions reservedbox held openBuy
Left: no space reserved, so the late image shoves the text and button downward as it loads. Right: width and height reserved up front, so the image fills its box and nothing moves.

Common causes and fixes

Images and video without dimensions. Always set width and height attributes (or a CSS aspect-ratio) so the browser reserves the box before the file arrives. Modern browsers turn those attributes into an aspect-ratio automatically, so the placeholder scales responsively.

<img src="/photo.avif" width="1280" height="720" alt="…" />

Content injected above existing content. Banners, cookie notices, “you have 1 new message” bars, and late-loading ads all push everything below them down. Reserve their space with a fixed-height container, or render them in a way that overlays rather than displaces (a position: fixed bar, for instance).

Web fonts that reflow text. When a fallback font is swapped for the web font, differing metrics reshape every paragraph. Use font-display: optional or swap combined with size-adjust / the f-mods font descriptors (ascent-override, descent-override, size-adjust) to match the fallback’s metrics so the swap is invisible.

Animating layout properties. Animating top, left, width, or height triggers layout on every frame and can register as shifts. Animate transform and opacity instead — they run on the compositor and never move surrounding content.

Two ways to measure: field vs lab

Here is the distinction that decides whether your optimization work is real or theater.

LABLighthouse · DevTools · CIone device, one networkrepeatable, debuggablecatches regressions pre-shipno real INP (no real users)your fast laptop liesFIELD (RUM)web-vitals · CrUXthousands of real visitsreal phones, real networksthe 75th percentile that ranksnoisy, lags real-timeharder to attribute a cause
Lab data is one controlled run on your machine — great for debugging and repeatable. Field data is many real visits across real devices and networks — the ground truth of what users feel, and what search ranking actually uses.

Lab (synthetic) data comes from a tool that loads your page in a controlled environment — Lighthouse, WebPageTest, DevTools, your CI pipeline. It is repeatable and perfect for debugging: change one thing, re-run, see the delta. But it runs once, on one simulated device, and it cannot produce a real INP because there is no real user clicking around. Lighthouse gives you a Total Blocking Time proxy instead.

Field data, also called Real User Monitoring (RUM), is collected from actual visitors’ browsers as they use the site. This is what Google’s Chrome User Experience Report (CrUX) aggregates, and it is what feeds search ranking. It is the truth — but it is noisy, it lags (CrUX is a 28-day rolling window), and a single bad number does not tell you which interaction on which page was slow.

Collecting field data with web-vitals

The web-vitals library is the standard way to measure the three metrics from real users. It is tiny, wraps the raw browser APIs correctly (including all the fiddly edge cases around bfcache, tab visibility, and finalizing values), and normalizes them.

import { onLCP, onINP, onCLS } from "web-vitals";

function report(metric) {
  // metric = { name, value, rating, delta, id, entries, navigationType }
  const body = JSON.stringify({
    name: metric.name,     // "LCP" | "INP" | "CLS"
    value: metric.value,   // ms for LCP/INP, unitless for CLS
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    id: metric.id,         // unique per page load
  });
  // sendBeacon survives the page unloading; fetch(keepalive) is the fallback.
  navigator.sendBeacon("/rum", body);
}

onLCP(report);
onINP(report);
onCLS(report);

Each on* function calls your callback once the value is ready — LCP and CLS finalize when the page is hidden or unloaded; INP updates as worse interactions happen. Two important behaviors:

  • Pass { reportAllChanges: true } if you want a callback on every update rather than only the final value. Most production RUM does not use this — you want the finalized number.
  • Under the hood, each function spins up a PerformanceObserver. Call each on* function once per page; calling them repeatedly leaks observers.

Attribution: from a number to a cause

A raw INP of 450ms tells you there is a problem but not where. The library’s attribution build adds a rich breakdown so you can diagnose from field data alone.

import { onINP } from "web-vitals/attribution";

onINP((metric) => {
  const a = metric.attribution;
  console.log(a.interactionTarget);   // e.g. "button#checkout" — the element
  console.log(a.interactionType);     // "pointer" | "keyboard"
  console.log(a.inputDelay);          // ms the thread was busy before handling
  console.log(a.processingDuration);  // ms your handlers ran
  console.log(a.presentationDelay);   // ms to render the next frame
  console.log(a.longAnimationFrameEntries); // the LoAF entries, if any
});

Now the field data is actionable: “INP is bad, it is the #checkout button, the time is dominated by processingDuration” points straight at a heavy click handler. If inputDelay dominated instead, the culprit is something else hogging the thread when the click arrived.

Under the hood: PerformanceObserver

The library is convenience over a real browser API — the Performance Timeline. You can read the same signals directly, which is useful for custom metrics or when you do not want a dependency.

// Largest Contentful Paint, raw.
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const last = entries[entries.length - 1]; // the latest candidate is the LCP
  console.log("LCP element:", last.element);
  console.log("LCP time:", last.startTime);
}).observe({ type: "largest-contentful-paint", buffered: true });
// Cumulative Layout Shift, raw. Sum shifts the user did not cause.
let cls = 0;
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) cls += entry.value;
  }
  console.log("CLS so far:", cls);
}).observe({ type: "layout-shift", buffered: true });

Two details worth internalizing:

  • buffered: true replays entries that occurred before the observer was created. LCP and layout shifts happen early in page load, often before your script runs; without buffered you would miss them.
  • The entry types you care about here are largest-contentful-paint, layout-shift, event (and first-input) for interactions, plus long-animation-frame and longtask for diagnosing what blocked the thread. Feature-detect with PerformanceObserver.supportedEntryTypes before observing an unsupported type.

A practical audit → diagnose → fix loop

Chasing vitals without a process turns into whack-a-mole. Here is a loop that works.

1. Measurefield / RUM2. Diagnoselab / attribution3. Fixone change4. Verifylab, then field5. Budgetguard in CIrepeat on the next worst metric
A repeatable loop: measure the field to find the real problem, reproduce it in the lab to find the cause, fix, verify in the lab, then confirm the field number moves and guard it with a budget.
  1. Measure the field first. Look at CrUX or your own RUM. Which metric is failing the 75th-percentile bar, on which pages, on which devices? Do not start optimizing what is already green.
  2. Reproduce and diagnose in the lab. Open the failing page in DevTools with CPU and network throttling that matches a mid-range phone. Use the Performance panel to find the long task, the late image, or the shifting element. Attribution data from RUM tells you where to point the lab.
  3. Make one change. Preload the hero, split the long task, reserve the image box. One at a time, so you can attribute the improvement.
  4. Verify. Re-run the lab to confirm the mechanism improved, then watch the field number over the following weeks — remember CrUX lags on a 28-day window.
  5. Set a budget and guard it. Once green, keep it green.

Setting a budget

A performance budget is a hard limit your build fails against, so a regression is caught before users feel it. Lighthouse CI reads a simple JSON assertion config:

{
  "ci": {
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time": ["error", { "maxNumericValue": 200 }]
      }
    }
  }
}

Note that the lab budget uses Total Blocking Time as the stand-in for INP — you cannot measure real INP without real users, so TBT is the CI-friendly proxy that correlates with it. Pair the lab budget with an alert on your field data so both halves of the loop have a tripwire.

Summary

  • Core Web Vitals are three user-centric metrics, each with a good threshold at the 75th percentile: LCP ≤ 2.5s (loading), INP ≤ 200ms (responsiveness), CLS ≤ 0.1 (stability). All are Baseline widely available.
  • INP replaced FID as a Core Web Vital in March 2024. It measures the full latency of interactions across the whole visit, not just the first input’s delay.
  • LCP is fixed by speeding up TTFB, removing render-blocking resources, and getting the hero image discovered and loaded early — never lazy-load it.
  • INP is fixed by breaking up long tasks and yielding (scheduler.yield() where available, with a setTimeout fallback), deferring non-urgent work, and moving heavy compute to a worker.
  • CLS is fixed by reserving space: set width/height or aspect-ratio on media, hold room for injected content, match font metrics, and animate only transform/opacity.
  • Field (RUM) data is the truth; lab data is for debugging. Collect the field with the web-vitals library (its attribution build turns a number into a cause), send it with sendBeacon, or read the raw PerformanceObserver timeline for custom needs.
  • Run the audit → diagnose → fix loop and lock in wins with a performance budget in CI, using Total Blocking Time as the lab proxy for INP.