RUM and field profiling: lab lies, the field decides
Wire onLCP, onINP, onCLS to a beacon carrying route, release, and device class — without those dimensions field data answers nothing. INP attribution splits input delay, processing, presentation. Sample the React Profiler at 1 percent, alert on p75 per release ring, keep PII out.
The question was simple: “Which release made checkout slow?” Answering it took three weeks. The team had vitals beacons — value, metric name, timestamp — and fourteen deploys in the suspect window. No release tag on the beacon, no route, no device class. They correlated deploy timestamps against hourly INP aggregates, got three plausible suspects, redeployed each in isolation to a staging ring that had no real users, reproduced nothing, and finally found it by bisect-redeploying production over four nights: a date-picker dependency bump that doubled input handling time, but only on mid-range Android — which is why staging, on M-series laptops, stayed green and Lighthouse kept printing 96. After the incident they added three fields to the beacon: route, release, device class. The next “which release regressed” question was one query and took thirty seconds. The lab tells you what can be slow; only the field tells you what is slow, for whom, and since when.
Lab lies, field decides
A Lighthouse run on your machine is a single sample from a hardware distribution your users do not inhabit. The median user device has a CPU several times slower than a developer laptop; the field adds throttled networks, 4GB-RAM phones, battery-saver mode, twenty open tabs, ad-blockers, and sessions that last eight hours instead of the lab’s ninety seconds. Lab tooling (Lighthouse, WebPageTest, your local Profiler) remains essential — it is controlled, so it can detect regressions pre-release on a fixed device and reproduce them deterministically. But it answers “can this be slow?” never “is this slow for real users?”. Field data — Real User Monitoring — is the ground truth the Core Web Vitals program is built on: Chrome aggregates real-session LCP, INP, and CLS per origin, and the passing bar is p75 of real page views. A senior team runs both and never confuses their roles: lab as the pre-release regression gate, field as the verdict. When you find yourself arguing with field data using a Lighthouse score, that is the confusion this lesson is designed to prevent.
Wiring web-vitals: three dimensions or noise
The web-vitals library is the standard instrument: onLCP, onINP, onCLS callbacks fire when a metric finalizes — which for INP and CLS is late in the page lifecycle, so reporting must flush on visibilitychange/pagehide via sendBeacon, not on load. The wiring is twenty lines; what makes the data actionable is the dimensions you attach:
import { onLCP, onINP, onCLS } from 'web-vitals/attribution';
function send(metric) {
navigator.sendBeacon('/vitals', JSON.stringify({
name: metric.name, // 'INP' | 'LCP' | 'CLS'
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
route: routePattern(), // dimension 1: '/orders/:id', NOT the raw URL
release: __RELEASE__, // dimension 2: build id injected at deploy
device: deviceClass(), // dimension 3: coarse bucket, e.g. memory + cores
attr: summarize(metric.attribution),
}));
}
onINP(send); onLCP(send); onCLS(send);Route tells you which surface regressed — a global number averages your marketing page with your data grid into meaninglessness. Release turns “it got slow sometime” into “ring N is 40% worse than ring N−1” — the regressed-release question becomes a single group-by. Device class separates “we shipped a regression” from “a retail campaign brought in low-end Android traffic” — a traffic-mix shift moves global percentiles with zero code change. Missing any one of the three collapses distinct populations into an average that cannot answer questions; this is exactly the three-week hunt from the Hook.
The global p75 INP on your dashboard reads 180 ms — green. Support keeps escalating search-page lag from Android users. What is wrong with the measurement?
INP attribution: where the milliseconds went
INP is roughly the worst interaction of the page view (for busy pages, a high percentile of all interactions — so one bad click among hundreds still counts). The attribution build splits each candidate interaction into three phases, and the split decides where you work: input delay — the event sat in the queue because the main thread was busy with something else (hydration, a chart update, third-party script); processing duration — your handlers ran, including the synchronous React render and commit they trigger; presentation delay — the frame after handlers took long (layout, paint, too much DOM). Attribution also names the interaction target element and event type. The React-specific reading: a fat processing slice on keystrokes is usually a large synchronous render cascade — the fixes are memoization, list virtualization, or moving the update into a transition. A fat input delay on clicks right after navigation is usually hydration or data parsing hogging the thread — handler optimization does nothing there. Long Tasks and the newer Long Animation Frames observer (PerformanceObserver with type long-animation-frame) attribute the busy thread to scripts, closing the loop on “busy with what?”.
The React Profiler in production, sampled
The Profiler component’s onRender callback reports per-commit timings: id, phase (mount/update), actualDuration (time spent rendering the subtree this commit), baseDuration (estimated full-render cost without memoization). Production builds strip profiling by default; opting in means shipping the profiling build (react-dom/profiling), which adds a few percent overhead. The discipline that makes this viable: sample sessions, not commits — enable profiling for ~1% of sessions chosen at session start, beacon aggregated actualDuration per Profiler id, and keep every session’s data tagged with the same three dimensions. Full tracing on every session is too heavy twice over: the runtime overhead lands on exactly the slow devices you care about, and the beacon volume costs more than the insight. One percent of millions of sessions yields tight percentile estimates per release ring — enough to see that OrdersTable commits got 3× slower in release 142 without profiling a single extra user.
▸Why this works
Why p75 and not p50 or p99? p50 declares victory while a quarter of your users suffer — half your traffic being fine is a low bar. p99 is dominated by what you cannot fix: dying batteries, background tabs, antivirus scans, 2G roaming — chasing it burns quarters on noise. p75 is the deliberate compromise the Core Web Vitals program standardized: improve it and you have demonstrably improved the experience of most users, while the target stays achievable and attributable to your code. Teams with rich-interaction apps often track p95 internally as an early-warning series — but they alert and set goals on p75.
Percentiles per ring, alerts on impact, and the privacy line
The query that answers “which release regressed” is: p75 INP per route, grouped by release ring, compared ring-over-ring — canary at 1% gives you the answer before full rollout if traffic clears the minimum sample size. Alert design follows one principle: page on user impact, not on metrics. A page-worthy alert is a conjunction — route p75 above threshold AND sustained for N minutes AND traffic above a floor — because any single condition alone produces noise: percentiles on thin traffic swing wildly, transient spikes self-heal, and one slow synthetic probe is not an incident. Soft drift (p75 creeping 5% per week) goes to a weekly review channel, not the pager. And the privacy line is non-negotiable: performance beacons carry no PII — route patterns instead of raw URLs (querystrings leak tokens and emails), coarse device buckets instead of fingerprints, no user ids. A perf beacon that can identify a user is a data-protection incident waiting for an auditor; design the schema so the question never arises.
INP attribution for clicks on an orders row shows: input delay 420 ms, processing 35 ms, presentation 20 ms. The team plans a sprint memoizing the click handler path. What does the attribution actually say?
- 01Name the three dimensions every vitals beacon needs and what question each one unlocks.
- 02Decode an INP measurement: define the three phases of the attribution split and the distinct fix each one points to.
Lab and field tooling answer different questions and a senior team never swaps their roles: Lighthouse and local profiling are controlled regression detectors that say what can be slow; Real User Monitoring is the verdict on what is slow, for whom, and since when — which is why Core Web Vitals are defined on field data at p75 of real page views. The instrument is the web-vitals library — onLCP, onINP, onCLS, flushed via sendBeacon on pagehide because INP and CLS finalize late — and the value of the data is decided by three beacon dimensions: route pattern (which surface), release (which deploy — ring-over-ring p75 makes the regressed-release question a single query), and device class (regression versus traffic-mix shift). INP reads through its attribution split: input delay is main-thread contention from other work and points at Long Task/LoAF attribution, processing is your handlers plus the synchronous render they trigger and points at memoization or transitions, presentation is paint cost and points at DOM size. The React Profiler runs in production via the profiling build with onRender beacons, but sampled at about 1% of sessions — full tracing taxes exactly the slow devices you care about and drowns you in beacons; sampled percentiles per release ring still expose a 3× commit regression precisely. Alerts page on user impact, never single metrics: threshold AND sustained duration AND traffic floor, with slow drift routed to review instead of the pager. And the privacy line is structural: route patterns not URLs, coarse device buckets, no user ids — a perf beacon that can identify a user is an incident you scheduled for later. Now when you see an INP complaint that your Lighthouse score does not explain, reach for the three-dimension query first: route, release, device class — the answer is almost always in the segment the global average was hiding.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.