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.
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.
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:
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.
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
requestIdleCallbackor a post-paintsetTimeoutso 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.
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.
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 eachon*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: truereplays entries that occurred before the observer was created. LCP and layout shifts happen early in page load, often before your script runs; withoutbufferedyou would miss them.- The entry types you care about here are
largest-contentful-paint,layout-shift,event(andfirst-input) for interactions, pluslong-animation-frameandlongtaskfor diagnosing what blocked the thread. Feature-detect withPerformanceObserver.supportedEntryTypesbefore 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.
- 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.
- 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.
- Make one change. Preload the hero, split the long task, reserve the image box. One at a time, so you can attribute the improvement.
- 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.
- 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 asetTimeoutfallback), deferring non-urgent work, and moving heavy compute to a worker. - CLS is fixed by reserving space: set
width/heightoraspect-ratioon media, hold room for injected content, match font metrics, and animate onlytransform/opacity. - Field (RUM) data is the truth; lab data is for debugging. Collect the field with the
web-vitalslibrary (its attribution build turns a number into a cause), send it withsendBeacon, or read the rawPerformanceObservertimeline 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.