Skip to content

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 visibilitychange to hidden, not unload; web-vitals already does this. Hand-rolled unload listeners 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.