What Core Web Vitals actually measure
Core Web Vitals are three numbers Google uses to ask one question: when a real person opens your page, is it any good to use? The metrics are not abstract. Each one corresponds to a thing a user can feel.
LCP (Largest Contentful Paint) answers: when does the main content show up? Not the spinner, not the skeleton. The hero image, the headline, the product photo. The thing the user came to see.
INP (Interaction to Next Paint) answers: when I tap or click something, does the page respond? It measures the delay between your finger and the next visual update. If it feels laggy, INP is bad.
CLS (Cumulative Layout Shift) answers: does stuff jump around while I am reading? You start reading a paragraph, an ad loads above it, the paragraph slides down, you lose your place. That is CLS.
That is the whole story. Three questions about what a person experiences. Everything else is implementation detail.
The thresholds, and why these specific numbers
Google publishes target values for each metric. Pass all three and you are in the “good” bucket.
| Metric | Good | Needs work | Poor |
|---|---|---|---|
| LCP | <2.5s | 2.5s to 4s | >4s |
| INP | <200ms | 200ms to 500ms | >500ms |
| CLS | <0.1 | 0.1 to 0.25 | >0.25 |
The numbers are not pulled from thin air. They come from research on what users perceive as fast, responsive, and stable. 2.5 seconds for LCP is roughly the boundary where pages start to feel sluggish to most people. 200ms for INP is around the threshold where interaction feels instant versus delayed. 0.1 for CLS is small enough that most users do not consciously notice the shift.
There is a second number that matters: the 75th percentile. Google does not look at your average user. It looks at the slowest quarter. If your p75 LCP is 2.5 seconds, that means 75% of your real visits were faster, and the worst 25% were slower. The reason is simple. Averages hide pain. A site can have a great average and still be unusable for a quarter of its audience. p75 forces you to care about the people on slow phones and weak networks.
Lab data lies. Field data tells the truth.
There are two ways to measure performance, and they answer different questions.
Lab data is what you get from Lighthouse, PageSpeed Insights in lab mode, or WebPageTest. A controlled environment runs your site on a known machine, with a known network throttle, in a known state. It is reproducible, which is great for catching regressions in CI. But it is not your users.
Field data is what real visitors experience. Their phone is three years old, their browser has 40 tabs open, they are on a train with patchy 4G, and they have an ad blocker that broke half your scripts. Field data captures all of that.
If you only ever look at lab numbers, you will optimize for a machine that does not exist. The CrUX report (Chrome User Experience Report) is field data, and it is what Google uses for ranking. Your own RUM is field data, and it is what tells you which page on which device family is hurting which users.
The rule of thumb: lab to find issues fast, field to know what is actually shipping pain to people.
RUM in practice: capture, ship, look at it weekly
Real User Monitoring sounds heavy. It does not need to be. The minimum viable setup is three pieces:
- The
web-vitalslibrary running in the browser. - A small endpoint on your backend that accepts the data.
- A query you run once a week to see what is getting worse.
Here is what the browser side looks like:
import { onLCP, onINP, onCLS } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
page: location.pathname,
connection: navigator.connection?.effectiveType ?? 'unknown',
});
// sendBeacon survives page unload, fetch with keepalive is the fallback
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
}
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
That is it for the client. The library handles the tricky parts: when to fire LCP (it can change as larger elements load), when CLS becomes final (on page hide), how to track INP across all interactions during the visit.
On the backend, write the events into whatever store you already have. A simple Postgres table with name, value, page, connection, created_at is enough to start. Once a week, run something like:
SELECT
page,
PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) AS p75,
COUNT(*) AS samples
FROM vitals
WHERE name = 'LCP'
AND created_at > NOW() - INTERVAL '7 days'
GROUP BY page
HAVING COUNT(*) > 100
ORDER BY p75 DESC
LIMIT 20;
The slowest pages by p75 LCP, with enough samples to be meaningful. Same query for INP and CLS. That list is your todo list.
The most common LCP fix: the hero image
In the field, the single biggest LCP win on most sites is the hero image at the top of the page. Two things go wrong:
- The browser does not know about it early enough, so it loads after stylesheets and scripts.
- It has no width or height set, so the layout reflows when it arrives.
Here is the before:
<img src="/hero.jpg" alt="Product photo">
And here is the version that fixes both problems:
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<img
src="/hero.avif"
alt="Product photo"
width="1200"
height="600"
fetchpriority="high"
decoding="async"
>
The preload tells the browser to start fetching the image as soon as it sees the HTML, instead of waiting for the CSS to reference it. The fetchpriority="high" on the image itself reinforces that. The width and height let the browser reserve the slot before the image arrives, which also helps CLS. Modern formats like AVIF or WebP make the file 30 to 70 percent smaller than JPEG, which directly translates to faster LCP. We covered that in our WebP vs AVIF comparison.
If you do one thing this week, do this on your top three landing pages.
The most common CLS fix: reserve the space
CLS happens when something appears on the page after layout has already settled, and pushes existing content around. The two biggest culprits are images without dimensions and ads or embeds without a reserved slot.
For images, always set width and height (or use the modern aspect-ratio CSS):
<img src="/photo.jpg" alt="..." width="800" height="450">
.media {
aspect-ratio: 16 / 9;
width: 100%;
}
For ads, embeds, or any third-party widget, reserve the box before it loads:
.ad-slot {
min-height: 250px;
min-width: 300px;
}
The principle is the same in both cases. Tell the layout how big the thing will be before it arrives. The slot stays empty for a moment, then fills, but nothing else moves.
A bonus rule: never inject content above the fold based on async data. If you have to show a banner after a fetch, render it below existing content, or use a fixed-height container that animates the banner in without pushing.
The most common INP fix: break up long tasks
INP is usually high because something runs on the main thread for too long after a click or tap. The browser cannot paint the next frame until the JavaScript finishes. If your handler runs for 300 milliseconds, INP is at least 300 milliseconds.
The fix is to chunk the work and yield to the browser between chunks. Modern Chrome supports scheduler.yield():
async function handleClick(items) {
// Show feedback immediately
showSpinner();
for (const item of items) {
processItem(item);
// Let the browser paint and handle other input
if (scheduler.yield) {
await scheduler.yield();
} else {
// Fallback for older browsers
await new Promise(resolve => setTimeout(resolve, 0));
}
}
hideSpinner();
}
Each await releases the main thread. The browser can paint the spinner, respond to other input, and come back to your loop. Visually the work takes the same total time, but the page feels alive throughout.
Other common INP wins:
- Move heavy work (parsing, sorting large arrays, image processing) into a Web Worker.
- Defer non-critical work with
requestIdleCallback. - Audit your event listeners. A debounced resize handler that runs a layout-thrashing function is a classic INP killer.
For more on INP and what runs on the main thread, see our INP guide.
A workflow that actually works
You do not need to fix everything at once. The trick is to make this a weekly habit instead of a panic project.
- Monday morning. Look at last week’s p75 numbers, broken down by page and metric. Find the worst regression or the worst absolute value.
- Pick one thing. Not five. One. The slowest LCP page, or the worst INP interaction, or the section with the highest CLS.
- Fix it. Ship the fix. Verify in lab first if you want, but the truth comes from the next week of field data.
- Repeat. Next Monday, look again. The fix either worked or it did not. The data tells you.
This beats one-off audits every time. Sites are alive. New code lands, new images get added, a marketing team adds a new tag manager, and performance drifts. A weekly half hour of reviewing real numbers catches regressions before they become emergencies.
A few extra notes
Third-party scripts are the silent killer of all three metrics. Every analytics tag, chat widget, A/B test framework, and ad network adds bytes, blocks the main thread, and sometimes shifts layout. Audit them annually. Drop the ones that are not earning their keep.
Compression matters. Brotli for text, AVIF or WebP for images, properly cached fonts. None of this is exotic in 2026, but every site we audit has at least one place where it is not happening.
Server response time underlies everything. If your TTFB (Time To First Byte) is 800ms, you have already burned a third of your LCP budget before the browser has done anything. Cache aggressively, use a CDN, keep your origin lean.
And finally, keep an eye on the basics. A misconfigured CSP can block resources you depend on. Missing HSTS can mean unnecessary redirects. Code that is not efficient on the main thread shows up as bad INP. Performance is a system property, not a single setting.
Frequently asked questions
Do I need a paid RUM service?
No. The web-vitals library plus a simple endpoint on your own backend works fine for most sites. A paid service is worth it when you want pre-built dashboards, alerting, and detailed segmentation without writing it yourself, or when you have so much traffic that storage and querying become real engineering work.
My Lighthouse score is 95 but my CrUX numbers are bad. What is wrong?
Lighthouse runs on a fast simulated machine in a quiet environment. Your real users are on slower devices and worse networks. The gap between the two is normal. Trust the field data when you have it.
How long does it take for a fix to show up in field data?
CrUX uses a 28-day rolling window, so changes there are slow. Your own RUM data updates immediately, and you can usually see the impact of a fix within a few days, once you have enough samples.
Should I optimize for CrUX or for my own RUM?
Both. CrUX is what Google uses for ranking signals. Your own RUM gives you faster feedback and can segment by page, device, and country. They should agree directionally. If they do not, dig into why.