What Core Web Vitals are
Core Web Vitals are Google’s attempt to measure page experience with numbers that correlate with how a page actually feels to use. Three metrics, each capturing a distinct frustration:
- LCP (Largest Contentful Paint) — how fast the main content appears
- INP (Interaction to Next Paint) — how quickly the page responds when you interact
- CLS (Cumulative Layout Shift) — how much the page jumps around while loading
They matter for two reasons. First, they’re a genuine proxy for user experience — slow, janky, jumpy pages drive people away, and the numbers track that. Second, Google uses them as a ranking signal, so they affect traffic. Both reasons point the same direction, which is convenient.
LCP: how fast does the main thing show up?
Largest Contentful Paint measures when the largest visible element — usually a hero image, a heading, or a big text block — finishes rendering. It’s a proxy for “when does the page look ready?”
Targets: good ≤ 2.5s, needs-work 2.5–4s, poor > 4s (measured at the 75th percentile of real users).
Common causes of bad LCP:
- A large, unoptimized hero image
- Render-blocking CSS or JavaScript delaying paint
- Slow server response (high TTFB)
- Client-side rendering that makes the browser download and run JS before anything shows
Fixes:
<!-- Preload the LCP image so the browser fetches it early -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- Modern formats + explicit dimensions -->
<img src="/hero.webp" width="1200" height="600"
fetchpriority="high" alt="...">
<!-- Don't let non-critical JS block the initial render -->
<script src="/analytics.js" defer></script>
The highest-leverage LCP win is usually the hero image: serve it in WebP/AVIF, size it to what’s actually displayed (not a 4000px original scaled down in the browser), and give it fetchpriority="high" so it isn’t stuck behind less important downloads. defer on non-critical scripts stops JavaScript from holding up the paint.
If your framework renders on the client, the biggest lever is server-rendering or statically generating the above-the-fold content so the browser paints real content immediately instead of waiting on a JS bundle.
INP: how fast does the page respond?
Interaction to Next Paint measures the delay between a user action (tap, click, keypress) and the next visual update. It captures “I clicked and nothing happened” — the feeling of an unresponsive page. (INP replaced the older First Input Delay in 2024; INP is stricter because it measures all interactions across the visit, not just the first.)
Targets: good ≤ 200ms, needs-work 200–500ms, poor > 500ms.
The usual culprit: long JavaScript tasks hogging the main thread. The browser runs JS and rendering on a single thread, so while a heavy function runs, the page literally cannot respond to input — the click registers, but the paint that should follow is stuck behind your code.
// ❌ Processes 50,000 items in one blocking task — the page
// freezes for the whole duration, ignoring every click
function processAll(items) {
return items.map(heavyTransform);
}
// ✅ Yield to the browser between chunks so it can respond
async function processInChunks(items, chunkSize = 100) {
const results = [];
for (let i = 0; i < items.length; i += chunkSize) {
results.push(...items.slice(i, i + chunkSize).map(heavyTransform));
// Hand the main thread back so queued clicks can paint
await new Promise(resolve => setTimeout(resolve, 0));
}
return results;
}
Other INP fixes:
- Break up long tasks — anything over ~50ms blocks interaction; split it and yield.
- Offload heavy computation to a Web Worker — real parallelism, main thread stays free.
- Debounce expensive handlers — don’t run a costly search on every keystroke.
- Ship less JavaScript — the cheapest fix. Code that isn’t downloaded, parsed, and executed can’t block anything. Code-split and drop unused dependencies.
// Move genuinely heavy work off the main thread entirely
const worker = new Worker('/heavy-computation.js');
worker.postMessage({ data: largeDataset });
worker.onmessage = (e) => updateUI(e.data);
// The main thread stays free to respond while the worker crunches
CLS: does the page hold still?
Cumulative Layout Shift measures how much visible content moves unexpectedly during load. It’s the metric behind the universal frustration of going to tap a button, an ad loads above it, everything jumps, and you tap the wrong thing.
Targets: good ≤ 0.1, needs-work 0.1–0.25, poor > 0.25.
The causes are almost always the same few:
Images without dimensions. The browser doesn’t know how much space to reserve, lays out text, then shoves it down when the image arrives.
<!-- ❌ No dimensions — content jumps when the image loads -->
<img src="/photo.jpg" alt="...">
<!-- ✅ Dimensions let the browser reserve the space upfront -->
<img src="/photo.jpg" width="800" height="600" alt="...">
Setting width and height (or a CSS aspect-ratio) lets the browser hold the exact space before the image loads, so nothing shifts. This one attribute pair fixes a huge share of real CLS.
Ads, embeds, and iframes with no reserved space. Give the container a fixed min-height so the injected content fills a slot that already existed rather than pushing the page around:
.ad-slot {
min-height: 250px; /* reserve it before the ad arrives */
}
Web fonts swapping. Text renders in a fallback, then reflows when the web font loads. font-display: swap plus preloading the font, or sizing the fallback to match, keeps the shift small.
Content injected above existing content. A banner or notice inserted at the top pushes everything down. Reserve its space, or insert it where it won’t displace what the user is already looking at.
Field data vs lab data — the distinction that trips people
This is where most confusion lives, and it matters for what you optimize.
Lab data — from Lighthouse or your local dev tools — is a single synthetic run on one machine under simulated conditions. It’s reproducible and great for debugging: change something, re-run, see the effect. But it’s one run on your setup.
Field data — Chrome User Experience Report (CrUX) — is real measurements from real Chrome users on real devices and networks. It’s what Google actually uses for ranking, and it’s reported at the 75th percentile, meaning 75% of your users had an experience at least this good.
They routinely disagree, and understanding why saves you from optimizing the wrong thing:
- Your fast laptop on fibre shows great lab numbers, while real users on mid-range phones over spotty mobile networks have a much worse time. Field data catches what your dev machine hides.
- INP barely shows in lab tests because it needs real interactions over a whole session; a synthetic load doesn’t click around the way a person does. INP is fundamentally a field metric.
The rule: use lab data to debug (fast iteration, isolate a cause), but trust field data for the truth about what users experience and what Google scores. A perfect Lighthouse score with poor CrUX data means your real users are struggling regardless of what your laptop says.
How to measure
Lab / debugging:
- Lighthouse — Chrome dev tools → Lighthouse tab. Fast iteration.
- WebPageTest — deeper analysis, throttling, filmstrip view of the load.
Field / reality:
- PageSpeed Insights — enter a URL, get both lab (Lighthouse) and field (CrUX) side by side. The best single starting point.
- Search Console → Core Web Vitals report — how Google sees your whole site, grouped by issue.
web-vitalslibrary — measure real users yourself:
import { onLCP, onINP, onCLS } from 'web-vitals';
function send(metric) {
navigator.sendBeacon('/analytics', JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
}));
}
onLCP(send);
onINP(send);
onCLS(send);
Collecting your own field data means you see problems on the devices your audience actually uses, and you catch a regression the day it ships instead of a month later in CrUX.
A realistic priority order
Don’t chase a perfect score — chase “good” on all three for real users, then stop. Diminishing returns set in fast, and a 100 on Lighthouse buys nothing over a solid 90 that reflects real-world “good.”
- Fix CLS first — usually the cheapest (image dimensions, reserved ad slots) and the most viscerally annoying to users.
- Then LCP — optimize the hero image, cut render-blocking resources, server-render above-the-fold content.
- Then INP — break up long tasks, ship less JavaScript, offload heavy work.
Underneath all three is one theme: ship less, reserve space, and don’t block the main thread. Most Core Web Vitals problems trace back to too much JavaScript doing too much at the wrong time, and content that doesn’t reserve the room it will eventually need. Fix those habits and the scores follow — along with, more importantly, a site that actually feels good to use.
