The thresholds
Each metric is assessed at the 75th percentile of page loads. A page passes a metric when at least 75% of its experiences are at or under the "good" limit.
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP (Largest Contentful Paint) | Time until the largest visible element renders | ≤ 2.5 s | 2.5 s to 4 s | > 4 s |
| INP (Interaction to Next Paint) | Responsiveness to clicks, taps and key presses across the visit | ≤ 200 ms | 200 ms to 500 ms | > 500 ms |
| CLS (Cumulative Layout Shift) | Unexpected movement of visible content | ≤ 0.1 | 0.1 to 0.25 | > 0.25 |
INP replaced First Input Delay as a Core Web Vital in March 2024. It is a harder metric to pass, because it looks at interactions throughout the visit, not only at the delay before the first one.
Why p75, and how to read the split
The 75th percentile is high enough to reflect a poor experience that a meaningful share of users have, without being moved by a few very bad connections. Pair it with the split, since a single percentile hides the shape of the distribution.
Here is an example with 1,000 LCP samples for a route:
- 620 samples are good (≤ 2.5 s), which is 62%
- 240 samples need work (2.5 s to 4 s), which is 24%
- 140 samples are poor (> 4 s), which is 14%
Sorted by value, the p75 sample is number 750. The good group covers samples 1 to 620 and the needs-work group covers 621 to 860, so p75 lands in needs-work. To pass, you must move 130 more samples into good (750 minus 620). That is a concrete target, and it tells you more than "LCP is 3.1 s".
The split also exposes something p75 cannot. A route with 76% good and 24% poor passes, yet nearly a quarter of its users have a bad time. Segment it before you conclude all is well.
Field data versus lab data
| Field (real user monitoring) | Lab (scripted page audit) | |
|---|---|---|
| Source | Real sessions | A scripted load on a chosen profile |
| INP | Measured | Not measurable without real interactions. Total Blocking Time is a rough stand-in |
| Reproducible | No | Yes, which makes it good for debugging |
| Authenticated pages | Yes | Only if you script a login |
| Delay | Depends on your pipeline | Immediate |
Public field datasets have two limits that catch teams out. They cover only the browsers that contribute to them, and pages behind login or with low traffic may not appear individually. For an internal banking portal or a staff application, they tell you nothing, which makes your own collection the only source of data.
Collecting the metrics yourself
The metrics come from standard browser performance APIs, so you can collect them with a
PerformanceObserver. This sketch shows the mechanics:
const loadId = crypto.randomUUID();
const route = location.pathname.replace(/\/\d+(?=\/|$)/g, '/:id');
let lcp = 0, cls = 0, inp = 0;
let windowValue = 0, windowFirst = 0, windowLast = 0;
new PerformanceObserver((list) => {
const entries = list.getEntries();
lcp = entries[entries.length - 1].startTime;
}).observe({ type: 'largest-contentful-paint', buffered: true });
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.hadRecentInput) continue;
const same = e.startTime - windowLast < 1000 && e.startTime - windowFirst < 5000;
windowValue = same ? windowValue + e.value : e.value;
if (!same) windowFirst = e.startTime;
windowLast = e.startTime;
cls = Math.max(cls, windowValue);
}
}).observe({ type: 'layout-shift', buffered: true });
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.interactionId) inp = Math.max(inp, e.duration);
}
}).observe({ type: 'event', durationThreshold: 40, buffered: true });
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
navigator.sendBeacon('/rum/vitals', JSON.stringify({ loadId, route, lcp, inp, cls }));
}
});
Four details in this snippet matter:
-
INP and CLS can change throughout a visit, so they are reported when the page moves to the
background.
sendBeaconis used because a normal request may be cancelled while the page is being hidden. -
That event may fire more than once in a visit.
loadIdidentifies a single page load, so your backend should keep the latest values per id, not add them up. -
The route is normalised. Without that step,
/customers/8812and/customers/8813become separate series, which creates a cardinality problem and leaves each route with too few samples to read. - It is simplified. Real INP ignores one of the highest interactions for every 50, while this keeps the maximum. LCP also needs special handling for pages loaded in a background tab and restored from the back/forward cache. Open source reference libraries handle these cases, and a maintained collector is a better choice than a hand-rolled one in production.
The entries also carry diagnostic data. An LCP entry has element and
url, a layout shift has sources, and an event entry has
target, processingStart and processingEnd, which
together give the phases of an interaction. Your Content Security Policy needs
connect-src to allow the collection endpoint.
Browser support differs by metric, and engines have adopted these APIs at different times. Check current support tables, and treat absent samples as unknown, not as good. Your numbers may also be drawn mainly from one group of browsers.
Diagnosing each metric
LCP: find which phase is slow
LCP can be split into four phases, and each points to a different owner:
| Phase | Covers | Typical fix |
|---|---|---|
| Time to First Byte | Redirects, network, server response | Backend latency, caching, CDN |
| Resource load delay | Time before the LCP image starts loading | Preload it, and avoid lazy-loading it or injecting it from JS |
| Resource load duration | Download time | Image size and format, compression |
| Element render delay | Time from download to paint | Render-blocking CSS and JS, main thread work |
If TTFB takes most of the time, no amount of image optimisation will help. This is where frontend and backend data have to be joined. A high TTFB on a server-rendered page is a backend tracing problem.
INP: see what kept the main thread busy
An interaction has three phases: input delay (the main thread was busy with something else), processing duration (your event handlers ran), and presentation delay (the browser computed and painted the next frame). High input delay suggests a long task from elsewhere, frequently a third-party script. High processing points to your handler. High presentation delay usually means a large DOM or expensive layout.
A slow API call does not automatically mean poor INP. If the click handler starts a
fetch and shows a spinner immediately, the next paint is quick even though the
user waits two seconds for data. That delay still hurts users, but you have to measure it
separately, for example through fetch timings linked to backend traces.
CLS: name the element
Shifts within 500 ms of user input are excluded, since they are expected. CLS takes the
worst "session window" of shifts: a burst with gaps under 1 second, lasting at most 5
seconds. Frequent causes are images without dimensions, late-loading web fonts, and banners
injected above existing content. Reserve the space in CSS with aspect-ratio or
explicit dimensions, and record the sources of each layout shift to name the
element that moved.
Single-page applications
SPAs need special care because the metrics were designed around full page loads.
- A route change without a page load does not produce a new LCP in the standard API. Your "LCP" numbers describe the first route in the session only.
-
INP and CLS keep accumulating across route changes until the page is hidden. An
interaction that happens on
/reportsmay be attributed to the URL where the visit began, depending on when you read the route.
Fix the attribution by recording the route at the time of the event, where you can, and by tracking SPA route-change timing as a separate custom metric. Browser support for measuring soft navigations has been experimental, so check its current status rather than relying on it.
Joining the browser to the backend
Many poor experiences are not frontend problems. When a browser request carries a
traceparent header, a slow fetch in a session can be connected to the exact
backend trace that served it. Our W3C Trace Context
guide explains the header. One practical requirement for cross-origin APIs:
-
The API's CORS configuration must allow it:
Access-Control-Allow-Headers: traceparent. Without this, the browser's preflight fails and your instrumentation breaks real requests. Roll this out to the API first.
Only attach trace headers to your own origins. Sending them to third parties leaks identifiers, and it can trigger preflight failures on their side.
Alerting without noise
Web vitals are noisy at low volume. Sensible rules:
- Require a minimum sample count per route and window, so ten visits from a slow network cannot page anyone.
- Alert on sustained p75 changes per route and device class, and prefer a ticket over a page unless revenue or a public service depends on it.
- Annotate releases. Most step changes coincide with a deployment, and lining up changes against metrics answers the first question quickly.
How Wrentrix collects this
Wrentrix real user monitoring uses a
lightweight browser collector that reports LCP, INP and CLS at p75 with the good, needs-work
and poor split, tracks SPA routes, and groups JavaScript errors. Each fetch is linked to its
backend trace through traceparent, and per-app keys with allowed-origin lists
control who can send data. Session timelines put those events in sequence. All of it stays
on your own infrastructure, which is the main reason to choose it for staff portals and
regulated applications.
Frequently asked questions
What is a good INP score?
An INP at or below 200 ms at p75 counts as good, and anything above 500 ms counts as poor. The metric reflects one of the slowest interactions in a visit, not a typical one, so a single heavy handler can dominate it. Look at the phase breakdown to find out whether input delay, processing or presentation is responsible.
Why do my lab test and real-user numbers differ?
A lab audit runs one scripted page load on a simulated device, while field data covers real devices, networks, cache states and user behaviour. A page-load audit also cannot measure INP, because no real interaction takes place. Use lab tests to reproduce and debug, and use field data to decide whether users are actually affected.
How do you measure Core Web Vitals in a single-page application?
Collect with a browser collector built on the standard performance APIs, and record the current route with each metric. Be aware that standard LCP covers only the initial load, while INP and CLS continue across route changes until the page is hidden. Add a separate custom timing for route transitions, since that is what users of an SPA actually experience.
Can I monitor Core Web Vitals on internal or logged-in applications?
Yes, with your own real user monitoring. Public field datasets generally do not cover authenticated or low-traffic pages in useful detail, so a collector running in your own pages is the practical option. Check your privacy obligations for what you record, and normalise URLs so identifiers do not end up in your metrics.