RUM Beacons & Field Data Collection: Turning p75 Into an Actionable Signal
This guide operationalises the measurement half of Core Web Vitals & Measurement: where the lab tells you why a page is slow, a real-user monitoring beacon tells you for whom, how often, and by how much.
Every Core Web Vitals threshold — 2.5s for LCP, 200ms for INP, 0.1 for CLS — is assessed at the 75th percentile of real visits. That single fact makes field collection non-optional: a synthetic run on a fixed device and a fixed network produces one sample from a distribution whose shape you cannot see. Teams that ship only against Lighthouse routinely discover that their p50 is excellent, their p75 fails, and the gap is entirely explained by a device class or a route they never tested. A RUM pipeline closes that gap, but only if the beacon carries enough attribution to name the element or the handler responsible — a bare number tells you that you failed without telling you what to change.
What a Beacon Must Carry to Be Useful
A metric value alone answers "are we passing?". Engineering needs the answer to "what do I change on Monday?", and that requires four classes of payload on every beacon.
- The metric and its rating. Name, value, and the
good/needs-improvement/poorbucket the library assigned, so dashboards do not have to re-derive thresholds that may change. - Attribution. For LCP, the element selector and the four timing phases. For INP, the interaction target, the event type, and the input/processing/presentation split. For CLS, the largest shift source and the elements that moved. This is what turns a number into a ticket.
- Dimensions you will actually segment by. Route pattern (not the raw URL — cardinality explodes), device memory and hardware concurrency bucket, effective connection type, country, and whether the visit was a cold or warm cache load.
- Delivery context. A visit ID so multiple metrics from one page view can be joined, and a navigation type so back/forward-cache restores are analysed separately rather than skewing your cold-load p75.
The temptation to log everything is strong and expensive. Raw URLs, full user agents and unbounded custom properties inflate storage cost and make the aggregation query slow enough that nobody opens the dashboard. Fix the schema early: a dozen well-chosen dimensions outperform fifty ad-hoc ones.
Prerequisites
- The
web-vitalslibrary v4 or later, using the attribution build — the standard build reports values only, and re-deriving attribution by hand from rawPerformanceObserverentries is error-prone for INP in particular. - An endpoint that answers
204 No Contentquickly and does no synchronous work. A Worker at the edge is ideal; a route on your application origin will do, provided it never touches the database on the request path. - A storage layer that can compute percentiles over tens of millions of rows — a columnar warehouse, a time-series database, or a hosted analytics product. Percentiles over a relational row store will be the thing that eventually kills the project.
- Agreement on a sampling rate before launch. Retrofitting sampling after the fact invalidates historical comparisons.
1. Instrument the Metrics with Attribution
Load the attribution build and register every metric once, at the top of the app's entry point. Registration is cheap; the observers are passive and the callbacks fire at most once per metric per page view.
import { onLCP, onINP, onCLS, onTTFB } from 'web-vitals/attribution';
const queue = new Set();
function record(metric) {
const a = metric.attribution || {};
queue.add({
name: metric.name, // LCP | INP | CLS | TTFB
value: Math.round(metric.value * 1000) / 1000,
rating: metric.rating,
navType: metric.navigationType, // navigate | back-forward-cache | reload
target: a.interactionTarget || a.element || a.largestShiftTarget || null,
phases: {
ttfb: a.timeToFirstByte, delay: a.inputDelay ?? a.resourceLoadDelay,
process: a.processingDuration, present: a.presentationDelay
},
visitId: VISIT_ID, route: ROUTE_PATTERN
});
}
[onLCP, onINP, onCLS, onTTFB].forEach((on) => on(record, { reportAllChanges: false }));
// trade-off: reportAllChanges:false reports one final value per metric, which is
// what percentile dashboards want. Turn it on only in a debugging build — it
// multiplies beacon volume and makes p75 aggregation ambiguous.
The target field is the single highest-value column in the whole schema. Once you can group failing INP by interaction target, the work stops being "improve INP" and becomes "the filter dropdown's change handler costs 340ms on low-end Android" — a bug with an owner. That grouping technique is worked through in attributing INP to a component.
2. Deliver the Beacon Without Losing It
The hardest engineering problem in RUM is not measurement, it is delivery. INP and CLS are only final when the page is being unloaded, which is precisely the moment the browser stops honouring ordinary requests. Three rules keep loss rates under 1%.
Flush on visibilitychange, not unload. The unload and beforeunload events do not fire reliably on mobile and disable the back/forward cache outright. The hidden transition of visibilitychange fires on tab switch, app switch and navigation away, on every platform.
Use navigator.sendBeacon. It queues the request in the browser process, so it survives the document being torn down, and it never blocks the unload path. Keep the payload under the ~64KB limit and fall back to fetch(..., { keepalive: true }) where sendBeacon returns false.
Batch, then flush once. Sending four separate beacons per page view quadruples request volume for no analytical gain. Accumulate into a queue and flush the whole set together.
function flush() {
if (!queue.size) return;
const body = JSON.stringify({ ts: Date.now(), metrics: [...queue] });
queue.clear();
const ok = navigator.sendBeacon('/rum', new Blob([body], { type: 'application/json' }));
if (!ok) fetch('/rum', { body, method: 'POST', keepalive: true }).catch(() => {});
}
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') flush();
});
// trade-off: sendBeacon cannot report failures and gives you no response, so a
// broken collector is silently invisible. Monitor collector-side request volume
// against session counts, not client-side success, to detect delivery loss.
The full failure taxonomy for this step — including why a beacon sent from a pagehide handler in a single-page app can double-count — is covered in sending Web Vitals with sendBeacon on visibility change.
3. Choose a Sampling Rate You Can Defend
Sampling is a statistics decision disguised as a cost decision. The question is not "how much data can we afford?" but "how small a segment do we need to be able to see?".
Sample by visit, never by beacon: a rate applied per metric produces page views where LCP was recorded and INP was not, and any join across metrics silently loses rows. Decide once at session start, stamp the decision into the visit ID, and let every beacon from that visit inherit it.
Keep the rate stable over time, and store it. When you later raise the rate from 10% to 25% to investigate a regression, historical comparisons remain valid only if each row knows which rate produced it.
Deconstructing the Numbers: Why Your p75 and CrUX Disagree
The first serious question every RUM rollout faces is why the in-house dashboard reads 2.1s while the Chrome UX Report says 2.6s. Both are usually correct; they are measuring different populations.
- Browser coverage. CrUX contains Chrome only, on visits from opted-in users. Your beacon fires in every browser that supports the APIs — and Safari reports no INP at all, so an iOS-heavy audience quietly removes the slowest population from one metric and not the others.
- Aggregation window. CrUX publishes a rolling 28-day distribution. A dashboard showing yesterday's p75 will always look different, especially after a deploy, and neither number is wrong.
- The unit being aggregated. CrUX aggregates over origin or URL groups with its own weighting; your query aggregates rows you collected. If a single high-traffic route dominates your table but is grouped separately in CrUX, the two p75s are not comparable at all.
- Delivery loss. Beacons that never arrive are disproportionately from the slowest sessions — the ones that closed the tab in frustration. Loss biases your p75 optimistic, which is the most dangerous direction.
The reconciliation procedure, including how to build a Chrome-only, 28-day, origin-level view of your own data for an apples-to-apples check, is in CrUX vs your own RUM.
Advanced Diagnostics
Back/forward-cache restores need their own bucket. A bfcache restore produces a near-zero LCP and an INP that reflects only post-restore interactions. Mixed into cold loads, these entries drag the p75 down and hide real regressions. Filter on navigationType === 'back-forward-cache' and report it as a separate series.
Single-page navigations are not page views. In an SPA, onLCP fires once for the hard load; subsequent route changes produce no new LCP. Reporting a soft navigation as a page view therefore under-counts LCP relative to visits. Either scope the metric to hard loads only or adopt the soft-navigation API where it is available, but do not silently mix the two.
Long sessions truncate INP. INP updates as the visit continues, so a 40-minute session's final value is reported only at the end. If your users keep tabs open for hours, add a periodic flush — every few minutes of visible time — so a browser crash does not lose the whole session.
Consent gating changes the population. If beacons only fire after a cookie banner is accepted, your data describes consenting users, who skew toward repeat visitors with warm caches. Where policy allows, send Core Web Vitals as anonymous aggregate telemetry outside the consent gate; where it does not, document the bias so nobody compares the number to CrUX without adjusting.
Validation, Budgeting and Alerting
Treat the pipeline itself as a system that can fail silently, and monitor it accordingly.
// Collector-side sanity checks, evaluated hourly.
// 1. Delivery ratio: beacons should track sessions within a stable band.
// A sudden drop means a client-side error, not a performance win.
// 2. Metric completeness: every visit that reported LCP should eventually
// report CLS. A widening gap means the flush is being missed on unload.
// 3. Cardinality guard: reject rows whose route pattern is not in the known
// set, so a bad release cannot explode storage with raw URLs.
// trade-off: strict rejection loses data during a legitimate route launch.
// Log rejects to a side table for a week rather than discarding them.
For alerting, compare a rolling 24-hour p75 against the same window from the previous week, per route, and alert on a relative regression rather than an absolute threshold crossing. Absolute thresholds fire constantly for routes that sit near the boundary and never fire for a route that degrades from 0.8s to 2.2s while still technically passing.
Pair field alerting with the lab assertions described in the best Lighthouse CI setup for frontend pipelines. CI stops a regression before it ships; RUM catches the regressions CI cannot model — a third-party tag change, a new device mix, a CDN incident.
FAQ
Do I need a RUM pipeline if I already have CrUX?
Yes, if you intend to fix things rather than only report them. CrUX gives you a trustworthy, comparable p75 but no attribution: it cannot tell you which element was the LCP candidate or which handler blew the INP budget, and it only covers Chrome traffic at origin or popular-URL granularity. Your own beacons add the element selectors, route patterns and device segments that turn a failing metric into an assigned ticket. Use CrUX as the scoreboard and your RUM as the diagnostic.
What sampling rate should I start with?
Start at 100% for the first two weeks so you can see the real distribution and the real cost, then reduce until the smallest segment you care about still collects roughly a thousand samples a day. For most sites that lands between 10% and 25%. Sample per visit rather than per beacon, and store the rate on every row so historical comparisons stay valid when you change it.
Why do some page views report LCP but never INP?
Because the user never interacted, or because the browser does not support the metric. INP requires at least one qualifying interaction, so bounce visits legitimately have none, and Safari does not report it at all. Treat missing INP as absent rather than zero — averaging zeros into the distribution makes a failing p75 look passing.
Can the beacon itself hurt performance?
Only if you get the delivery mechanism wrong. The observers are passive and cost microseconds. The risks are a synchronous XHR on unload, a large JSON payload serialised on the main thread during the hide transition, and a collector endpoint on a cold third-party origin that forces a DNS lookup and TLS handshake at the worst possible moment. Use sendBeacon, keep the payload small, and host the collector on an origin the page already connected to.
Related
- Understanding Core Web Vitals Thresholds — what the p75 boundary means before you start measuring it.
- Profiling Event Handlers for INP — take an attributed interaction target from the field and reproduce it in the Performance panel.
- Sending Web Vitals with sendBeacon on visibility change — the delivery details that decide whether your data arrives at all.
- Attributing INP to a component — map an interaction target back to the component that owns it.
- CrUX vs your own RUM — reconcile two correct numbers that disagree.
- Why lab and field LCP disagree — the synthetic-versus-real gap on a single metric.