← Changelog

Real User Monitoring — Core Web Vitals

Performance 2026-06-27

Integrated the web-vitals library to capture LCP, INP, CLS, FCP, and TTFB from real browser sessions, beaconing to /api/cwv → D1 → KV snapshot pipeline with p50/p75/p95 percentiles and indefinite retention.

Why RUM instead of synthetic testing

Lighthouse and PageSpeed Insights give you a single synthetic measurement in a controlled environment. Real User Monitoring captures what actual visitors experience — on their devices, their connections, their browser load states. For Core Web Vitals, Google uses field data (CrUX), not lab data, to determine pass/fail thresholds. RUM matches what Google actually measures.

The web-vitals library (Google’s own) reports the same metrics Chrome uses for CrUX, so my data is directly comparable to what affects search rankings.

Data flow

  1. web-vitals library initializes on page load (client-side, ~2KB gzipped)
  2. Metrics fire as they become available: FCP and LCP on paint, CLS on layout shift, INP on interaction, TTFB immediately
  3. Each metric beaconed via sendBeacon to /api/cwv — non-blocking, fires even on page unload
  4. Worker handler writes to D1 cwv_log: metric name, value, page path, timestamp
  5. cwv-snapshot job runs on schedule: reads new rows incrementally, computes p50/p75/p95, writes to KV
  6. /page-speed/ dashboard reads from KV only — no D1 on page load

Why sendBeacon

fetch from an unload handler is unreliable — browsers cancel in-flight requests when navigating away. sendBeacon is designed exactly for this use case: it queues a small payload to be delivered asynchronously, survives page unload, and doesn’t block the next navigation. The cost is you can’t inspect the response, but for analytics this doesn’t matter.

Percentile choice: p75

The p50 (median) experience is often misleading for performance — it hides the tail. Google uses p75 as the threshold for Core Web Vitals pass/fail: your 75th percentile user needs to have a Good experience. I track p50, p75, and p95 but use p75 as the primary metric to match Google’s standard.

Reservoir sampling

Raw CWV values accumulate unboundedly if kept forever. I use reservoir sampling: each metric’s raw values are capped at 500 samples. New values replace random existing values once the cap is hit. This gives statistically valid percentile estimates with bounded storage. The error on p75 from a 500-sample reservoir is negligible for practical purposes.

Privacy

No IP address. No cookies. No session ID. No user identifier of any kind. The only data collected is the page URL and the metric value. No cross-page tracking is possible with this data. This is noted explicitly in the privacy policy.

CWV thresholds

MetricGoodNeeds ImprovementPoor
LCP≤ 2.5s2.5s – 4.0s> 4.0s
INP≤ 200ms200ms – 500ms> 500ms
CLS≤ 0.10.1 – 0.25> 0.25
FCP≤ 1.8s1.8s – 3.0s> 3.0s
TTFB≤ 800ms800ms – 1800ms> 1800ms

Per-path data is available for the top 100 paths by traffic volume. Data is retained indefinitely — the full site lifetime is visible in the /page-speed/ dashboard.

← All entries