Monitoring Core Web Vitals from real users

A lab audit score tells you how a page behaved once, on one simulated device. Monitoring Core Web Vitals means collecting LCP, INP and CLS from actual sessions and reading them by route, device and release.

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. sendBeacon is used because a normal request may be cancelled while the page is being hidden.
  • That event may fire more than once in a visit. loadId identifies 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/8812 and /customers/8813 become 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 /reports may 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.