Lesson 2 of 9 · 25m
Field Data: Instrumenting Real Users
Lab tests tell you what could happen; field data tells you what did. Collecting Web Vitals with attribution, sending them reliably, and slicing them so they point somewhere.
The most expensive performance bug I ever chased turned out to be a single A/B test variant that loaded a 2 MB personalization SDK for 15% of traffic. No lab test caught it, because no lab test was ever bucketed into that variant. The field data caught it within a day — once we started recording which variant each slow session had. Real-user monitoring (RUM) isn't optional; it's the only ground truth you get.
Two sources of field data
- CrUX (Chrome UX Report): Google's public dataset from opted-in Chrome users, a 28-day rolling window, origin- or URL-level. Free, zero setup, and it's what Search uses. But it's coarse, delayed, Chrome-only, and can't be sliced by your own dimensions.
- Your own RUM: you measure in the page and beacon results to your backend. Immediate, sliceable by route, release, device, A/B variant — and the only way to get attribution (what element, what script).
Use CrUX to know whether you have a problem; use your RUM to find out why.
curl -s "https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=$CRUX_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"origin": "https://shop.northwindgear.com", "formFactor": "PHONE"}' \
| jq '.record.metrics.largest_contentful_paint.percentiles.p75'Instrumenting with web-vitals
Don't write your own PerformanceObservers for this — the edge cases (bfcache restores, prerendering, background tabs, CLS session windows) are brutal. Use Google's web-vitals library, and use the attribution build:
import { onLCP, onINP, onCLS, onTTFB } from "web-vitals/attribution";
function send(metric) {
const body = JSON.stringify({
name: metric.name, // "LCP" | "INP" | "CLS" | "TTFB"
value: metric.value,
rating: metric.rating, // "good" | "needs-improvement" | "poor"
id: metric.id,
nav: metric.navigationType, // "navigate" | "reload" | "back-forward-cache" | ...
route: window.__ROUTE_TEMPLATE__, // "/product/:id", never the raw URL
release: window.__BUILD_SHA__,
attribution: metric.attribution, // trim this in production; it can be large
});
// sendBeacon survives page unload; fetch keepalive is the fallback
if (!navigator.sendBeacon?.("/rum", body)) {
fetch("/rum", { method: "POST", body, keepalive: true });
}
}
onLCP(send);
onINP(send);
onCLS(send);
onTTFB(send);With attribution, LCP reports the element selector plus timeToFirstByte, resourceLoadDelay, resourceLoadDuration, and elementRenderDelay — the sub-parts from the previous lesson, per real session. INP reports the interaction target plus inputDelay, processingDuration, and presentationDelay. That turns "INP is 480 ms" into "the filter checkbox on /search has 350 ms of processing."
Aggregate like the scoring does
Store raw values, then compute p75 per segment. Averages lie — a few 30-second outliers from backgrounded tabs will drag a mean anywhere. This runs in the sandbox:
function p75(values) {
const s = [...values].sort((a, b) => a - b);
return s[Math.ceil(s.length * 0.75) - 1];
}
const lcpByDevice = {
desktop: [1100, 1300, 1250, 1900, 1400, 1200, 2100, 1350],
mobile: [2100, 2600, 3900, 2400, 4800, 2200, 3100, 5200],
};
for (const [device, values] of Object.entries(lcpByDevice)) {
const mean = values.reduce((a, b) => a + b, 0) / values.length;
console.log(device, "p75:", p75(values), "mean:", Math.round(mean));
}Field notes
- Always segment by device class first. A blended p75 hides a failing mobile experience behind a fast desktop one.
- Record the route template, not the URL — otherwise you get 400,000 "pages" with three samples each.
- Report on
visibilitychangeto hidden, notunload; web-vitals already does this. Hand-rolledunloadlisteners also break the back/forward cache. - Tag every beacon with the release SHA. "When did this start?" is the first question in every regression, and you want a deploy to point at.
- Sample if you must, but sample sessions, not individual metrics — otherwise you can't correlate LCP and INP from the same visit.