Vitals in the Field
Loading, interaction responsiveness and visual stability measured on real devices — what each observer actually records, why field values differ from lab values by design, and why the boundaries are not yours to memorise.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What do the user-experience vitals actually measure, and why does the field value never match the one my build produced?
A person wants to see the thing they came for, tap it and have it respond, and read it without the page moving under their finger. Those three wishes are the whole of what the vitals try to express.
Run the audit tool locally, read the three scores, get them into the green band, and the page is fast.
The audit runs one page load on your machine with a simulated device profile. The field runs your real population, and the two produce different values from the same code because they are measuring different populations.
- The audit runs one page load on your machine with a simulated device profile. The field runs your real population, and the two produce different values from the same code because they are measuring different populations.
- The loading marker depends on what the largest element on screen happens to be, which depends on viewport size — so a phone and a desktop can select entirely different elements and produce incomparable values.
- The interaction marker cannot be produced by a page load at all. If nobody interacts, there is nothing to measure, and a lab run that clicks nothing reports nothing.
- Layout instability accumulates for as long as the page is open. A single-page application that a user keeps open for an hour has a shifting-content story a page-load audit never sees (Client-Side Routing).
- The audit tool's numbers are produced by a model with assumptions in it. They are useful for comparing two builds of your own site; they are not a prediction of what users get.
- Two of the three concerns are not measurable at all in some browsers, so a cross-browser average is an average of different things.
What is actually happening
In the browser, not in the framework.
- There are three concerns, and every vital is an attempt to express one of them as a number a person could act on.
- Loading — when did the main content appear? The vital for this is Largest Contentful Paint, which watches the largest image or text block in the viewport and keeps updating its candidate as the page renders. It stops updating at the first user interaction, because after that the user has seen enough to act (Loading: Why Content Arrives Late).
- Interaction responsiveness — when someone acted, how long until the page visibly responded? The vital for this is Interaction to Next Paint, which measures from the input event through the handler and all the resulting work to the next frame that shows the result. It reports over the whole page visit, not a single interaction (Interaction Responsiveness).
- Visual stability — did content move under the reader? The vital for this is Cumulative Layout Shift, which scores unexpected movement of already-visible content and sums the worst window of it across the visit. Movement within a short window after a user input is excluded, because it was expected (Visual Stability).
- The browser exposes each through
PerformanceObserverentry types, and each entry is a raw observation, not the vital. The vitals are defined on top of the entries: taking the final candidate, taking a high percentile of the interactions, taking the worst session window of shifts. - Every one of these has a finalisation problem: the true value is only known when the page goes away. That is why field collection reports at
visibilitychangeto hidden, and why an early beacon reports a value that is merely the story so far. - The boundaries that turn a value into a rating are published by the vitals working group and revised as the web and its devices change. They are a moving definition maintained outside your codebase, which is exactly why they do not belong inside it.
What this makes the browser do
And which of it is avoidable.
- The browser records these entries whether or not you observe them. Registering an observer with
buffered: truereplays what was already captured before your script ran, which is how a late-loading collector still sees an early paint. - Layout-instability entries are produced by the layout stage itself, so they cost the browser nothing extra; they are a report on work it already did (The Cost of a Change).
- Interaction entries require the browser to track from input dispatch through to the next paint. That accounting is cheap, but it means the number you read is a measurement of your handler plus your rendering work plus everything else queued on the main thread (Long Tasks).
- Your collection code is on that same main thread. Anything expensive done inside an observer callback is charged to the very interaction it is observing.
Three concerns, and the vitals that express them
Start from the concerns, because they are stable and the metrics are not. A person wants to see the content, act on it, and read it without it moving. Every vital that has existed, and every one that will replace the current set, is an attempt to put a number on one of those three wishes.
Each concern has a characteristic cause, and knowing which concern is bad tells you which part of the system to look at before you have opened a single trace. Loading is mostly the network and the critical path. Responsiveness is mostly the main thread. Stability is mostly things arriving late without reserved space.
- 1Loading — Largest Contentful Paint
Watches the largest image or text block in the viewport and records when it rendered, updating its candidate until the first interaction.
fails by A long critical path, a render-blocking resource, a late-discovered hero image, or a server that is slow to first byte (The Critical Rendering Path).
- 2Interaction responsiveness — Interaction to Next Paint
Measures each interaction from the input event through handler execution to the next paint showing the result, and summarises the visit.
fails by A busy main thread before the handler runs, an expensive handler, or a large render triggered by the state change (What a Component Costs to Render).
- 3Visual stability — Cumulative Layout Shift
Scores unexpected movement of already-visible content, summing the worst window across the visit; movement shortly after user input is excluded.
fails by Images and embeds without reserved space, fonts that swap metrics, banners injected above content, content replaced after a fetch resolves.
- 4Finalisation
Each value is only true when the page goes away, so field collection reports at the transition to hidden.
fails by Reporting early, or not at all when a tab is discarded — producing a dataset biased toward sessions that ended gracefully.
The vitals in the first three rows are the current expression of each concern. The concern is the durable part; the metric naming it is not.
Field and lab differ by design
A lab audit and a field dataset are not two attempts at the same measurement, and treating a gap between them as an error leads teams to spend weeks trying to make the wrong one move. The lab holds a device, a network, a viewport and a page state fixed. The field lets all four vary across every person who visited.
The differences are structural rather than incidental. Each row below is a property of the population, not a flaw in the tooling, and each one biases in a predictable direction — which is what makes the gap readable rather than merely frustrating.
| Input | Lab audit | Field dataset | Why the values diverge |
|---|---|---|---|
| Device | One machine, often with a synthetic throttle applied | Whatever your users own, including phones several years old | CPU-bound work scales with the device, so the responsiveness concern diverges most |
| Network | Modelled or shaped, and consistent between runs | Real congestion, real loss, real captive portals and proxies | Loading values in the field spread far wider than a modelled connection suggests |
| Viewport | One fixed size | Every size, and rotation mid-session | A different element is selected as largest, so the loading vital measures a different thing |
| Interaction | Usually none, unless a script performs one | Real people tapping real controls at unpredictable moments | The responsiveness concern often has no lab value at all to compare against |
| Session length | One page load, then the run ends | Minutes or hours, many route changes, tabs left open | Stability and responsiveness keep accumulating in the field long after a lab run would have finished |
| Cache and account state | Cold or warm by configuration, empty account | Mixed cache states and real data volumes | Client work that scales with the account is absent from the lab entirely |
| Browser | One, chosen by the tool | All of them, with uneven observer support | Part of your population contributes no value for some concerns at all |
Collecting them without becoming the problem
observe({ type, buffered: true }) form and the entry-type names shown are the specified ones, but which of the three types actually construct successfully varies: Chromium accepts all three, Gecko throws for layout instability, and WebKit throws for both layout instability and event timing — which is precisely why the failure is recorded as coverage rather than ignored.The collection code is short, and the parts that matter are the ones that are easy to leave out: buffered: true, so an observer registered after the fact still sees what already happened; reporting on the transition to hidden rather than on unload; and using sendBeacon so the report survives the page.
The discipline inside the callback is to do as little as possible. You are running on the main thread, inside the measurement, and every millisecond of work you do there is charged to the responsiveness figure you are trying to report honestly.
1// Register early, with buffered:true so entries recorded before this ran2// are replayed to the callback rather than lost.3const seen: PerformanceEntry[] = []4 5for (const type of ['largest-contentful-paint', 'layout-shift', 'event'] as const) {6 try {7 new PerformanceObserver((list) => {8 // Keep the callback trivial. It runs on the main thread, inside the9 // very interaction whose latency you are reporting.10 seen.push(...list.getEntries())11 }).observe({ type, buffered: true })12 } catch {13 // Not every engine implements every entry type. A missing observer is a14 // gap in coverage to record, not an error to swallow silently.15 coverage.missing.push(type)16 }17}18 19// The true value is only known when the page goes away.20addEventListener('visibilitychange', () => {21 if (document.visibilityState !== 'hidden') return22 navigator.sendBeacon('/rum', JSON.stringify({23 route: currentRoutePattern(), // pattern, never the filled-in URL24 release: RELEASE,25 deviceClass: classifyDevice(),26 browser: engineClass(), // so values are never pooled across coverage27 coverage, // which observers this browser gave us28 summary: summarise(seen), // computed once, here, not per entry29 }))30}, { capture: true })Three details carry the lesson: buffered: true recovers the past, the try/catch records missing coverage instead of pretending a browser reported nothing because nothing happened, and the beacon fires on the transition to hidden — the only reliable last moment a page gets.
How to build it
Most important first.
- Collect all three concerns from the field and treat the lab as a mechanism-finding tool, not as the source of truth about your users (Real User Monitoring).
- Report distributions per segment and per route, not a single site-wide score. Loading is dominated by the network, responsiveness by the device, and stability by what arrives late — three different causes with three different fixes.
- Read the diagnostic attributes on the entries, not just the values. The loading entry names the element it selected; the interaction entry names the target and splits input delay from processing from presentation; shift entries name the sources that moved. Those attributes are what make the number debuggable.
- Report at
visibilitychangeto hidden, and handle the back/forward cache: a restored page continues accumulating and must report again rather than being counted once (Persistent Client State). - Do not hardcode the rating boundaries anywhere in your code, your dashboards or your documentation. Reference the published definition, and let the tool that owns it do the classification (
SPEC-EVOLVING). - Record which browser produced each observation, and never pool values from browsers with different observer coverage into a single statistic.
- Tie every field value to a release and a route so a shift in the distribution has a suspect (Release Health).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Visual stability is an accessibility concern before it is a performance one. Content that moves relocates the target a switch, eye-tracking or motor-impaired user was aiming at, and moves the reading position of someone using a screen magnifier, who has no peripheral view to re-orient with (Contrast, Colour and Motion).
- Interaction responsiveness is measured from an input event, and keyboard and assistive-technology-driven activations are input events too. A component that is slow only when operated by keyboard produces a real interaction entry — but only if a keyboard user was in the sample.
- None of the three vitals says anything about whether the interface was operable. A page can score well on all three and be entirely unusable without a mouse, which is why these numbers are not an accessibility signal and must never be reported as one (Accessibility Testing).
- The collector must stay off the critical path. Telemetry that delays the first frame delays the point at which assistive technology has anything to announce.
What can go wrong
- Reporting only on
loador after a fixed delay, so the value transmitted is a partial one and systematically better than the truth. - Losing the report entirely when the tab is closed or discarded, biasing the dataset toward sessions that ended politely.
- Pooling browsers with different observer support and calling the result a site-wide figure, when it is a weighted average of "measured" and "not measured".
- Treating a single interaction's entry as the vital. The vital is a summary over the visit; one entry is one interaction.
- Chasing a value by suppressing the signal: hiding content until it is settled improves the stability score while making the loading experience worse, and no user is helped.
- A collector that is itself heavy, degrading the responsiveness figure it exists to report.
- Fossilising a boundary in a dashboard or an alert, so the definition drifts out from under a threshold nobody remembers choosing.
- The loading candidate changes as content renders. Reading the entry early gives a value that later observations invalidate, so only the final candidate is the vital.
- A user interaction stops the loading measurement. A fast interaction can freeze the candidate before the real largest element has arrived, making the value look better than the experience was.
- Late-arriving content — a font swap, a lazy image without reserved space, a third-party embed — shifts layout after the user is already reading, which is the entire mechanism behind the stability score (Images and Fonts).
- A page restored from the back/forward cache continues accumulating shifts and interactions from a state that was already reported once; report per lifecycle, not per document.
- Field vitals are collected per session and carry the same obligations as any other telemetry: minimise, scope, retain briefly, and be able to say what you hold (Analytics Events That Answer a Question).
- The diagnostic attributes are richer than they look. The loading entry can name an element and its source URL, the interaction entry names a target selector, and shift entries name source nodes — all of which can describe content that was on the user's screen.
- A selector or an element id can carry personal data when the markup does. Truncate and allow-list attribute reporting rather than shipping whatever the entry contains.
- Precise timing is a side channel. Cross-origin resource timings are coarsened unless the third party opts in with
Timing-Allow-Origin, which is a deliberate limit on what a page may learn about another origin. - A vitals beacon carrying a full URL leaks path parameters and query strings into a telemetry store. Report the route pattern (URL Parameters).
- "The audit tool says the page is fast, so it is." It says one machine loaded it quickly under one model of a device and a network.
- "Field and lab should agree." They should not. The populations are different by construction, and a persistent gap is information about your lab, not an error in either.
- "The loading vital is when the page finished loading." It is when the largest element in the viewport was rendered — an approximation of when the page looked ready, deliberately chosen because it correlates with perception rather than with completion.
- "The responsiveness vital measures my click handler." It measures from the input event to the next paint, including the delay before your handler ran and the rendering after it returned. Your handler is one of three parts.
- "Layout shift means animation." It means *unexpected* movement of content the user could already see. Movement the user asked for, and movement shortly after their input, is excluded.
- "We should put the threshold in the alert." The boundaries are maintained outside your codebase and revised as devices and expectations change; a copy of one is stale from the day it is committed (Deploying a Frontend).
Measuring it, and what changes in the field
- A field dataset from your own collection, segmented by device class, route, browser and release, reported as a distribution (Real User Monitoring).
- Public field datasets aggregated across the web, useful for comparison against a broader population but sampled and shaped by their own rules, not by yours.
- The Performance panel for the mechanism behind a value: which element was selected, which task blocked the frame, which node moved and why (A Mental Model of the Devtools).
- The lab audit for a before-and-after on one machine, which is a legitimate and useful thing to do as long as nobody reports it as what users experience.
- The classification of a value against current boundaries, done by the tool that owns the definition rather than by a number written into your code (Core Web Vitals as Signals, Not Scores in Observability & Performance).
- On a slow device the responsiveness concern dominates: the same handler runs on a fraction of the CPU, and everything else on the main thread queues in front of the frame (The Real Cost of JavaScript).
- On a slow network the loading concern dominates and the largest element is selected later, sometimes selecting a different element entirely as content arrives in a different order.
- On a small viewport the largest element is often a different element than on a desktop, so the loading vital is measuring a different thing per form factor rather than the same thing more slowly (Responsive Images).
- In a long-lived single-page session, stability and responsiveness keep accumulating across route changes while the loading vital was fixed at the initial navigation and never updates again.
- On a repeat visit with a warm cache the loading picture changes completely, which is why first-visit and repeat-visit populations should be separated before anything is concluded (Browser HTTP Caching).
- These three vitals were chosen because they generalise across the whole web, which necessarily makes them worse at describing your specific product than a metric you define yourself. Both are worth having; only one of them is comparable to anyone else.
- Reporting at page hide gets you the true value and costs you the ability to see it during the session, so live debugging needs a second, partial signal that you must be careful not to confuse with the vital.
- Rich diagnostic attributes make a value actionable and enlarge the privacy surface of every beacon.
- Optimising for a summary statistic can degrade the underlying experience — the reason to keep the mechanism in view and never let the number become the goal (Measure Before Optimising).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- SPEC-EVOLVINGThe names, the definitions and the boundaries that classify a value are maintained by the web vitals working group and have been revised repeatedly — the responsiveness vital in use today replaced an earlier one that measured only the delay before the first handler ran. Reference the published definition rather than committing any of it to your codebase, and expect the set itself to change again.
- BROWSER-SPECIFICObserver coverage is genuinely uneven: Chromium implements entries for all three concerns, Gecko implements the loading and paint entries but not layout instability, and WebKit implements paint and resource timing with no event-timing surface at all — so a value that exists for one part of your population simply does not exist for another, and pooling them produces a statistic about coverage rather than about speed.
- DEVICE-SPECIFICViewport size selects which element the loading vital measures and CPU class dominates the responsiveness figure, so the same build produces systematically different values on a phone and a laptop; segmenting by device class is not a refinement of the analysis, it is a precondition for it meaning anything.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — expressing a field distribution as an objective with an error budget, so that "is this fast enough" has an owner and a consequence.