9.1 — RUM implementation¶
Module 9 · Lesson 1 · 🟡 Intermediate · ~40 min
What you'll learn¶
- A complete, production‑ready vitals reporter with attribution
- The dimensions that make data actionable (and the ones that make it useless)
- A collection endpoint that doesn't become your next performance problem
- Dashboards and alerts that people actually look at
Copy‑pasteable versions of everything here are in examples/rum/.
Why your own RUM, not just CrUX¶
| CrUX | Your RUM | |
|---|---|---|
| Coverage | Chrome users who opt in | Everyone |
| Latency | 28‑day rolling window | Real time |
| Segmentation | Origin / URL group, form factor | Anything you record |
| Attribution | None | Full sub‑part breakdown |
| Correlation with revenue | Impossible | Direct |
| Custom metrics | No | Yes |
CrUX is for benchmarking and executive reporting. Your RUM is the instrument you actually work with. You need both, but the second one is what makes the work possible.
The reporter¶
// app/components/web-vitals.tsx
'use client';
import { useEffect, useRef } from 'react';
import {
onCLS, onINP, onLCP, onFCP, onTTFB,
type Metric,
} from 'web-vitals/attribution';
type VitalsPayload = {
name: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number;
id: string;
navigationType: string;
attribution: unknown;
// Dimensions
path: string;
pageType: string;
deviceClass: string;
connection: string;
saveData: boolean;
isReturning: boolean;
releaseSha: string;
experiments: string;
viewport: string;
ts: number;
};
export function WebVitals({ pageType }: { pageType: string }) {
const queue = useRef<VitalsPayload[]>([]);
useEffect(() => {
const conn = (navigator as any).connection ?? {};
const enrich = (metric: Metric & { attribution?: unknown }): VitalsPayload => ({
name: metric.name,
value: Math.round(metric.value),
rating: metric.rating,
delta: Math.round(metric.delta),
id: metric.id,
navigationType: metric.navigationType,
attribution: metric.attribution,
path: normalizePath(window.location.pathname),
pageType,
deviceClass: getDeviceClass(),
connection: conn.effectiveType ?? 'unknown',
saveData: !!conn.saveData,
isReturning: document.cookie.includes('returning=1'),
releaseSha: process.env.NEXT_PUBLIC_RELEASE_SHA ?? 'unknown',
experiments: document.documentElement.dataset.experiments ?? '',
viewport: `${window.innerWidth}x${window.innerHeight}`,
ts: Date.now(),
});
const report = (metric: Metric) => {
queue.current.push(enrich(metric as never));
};
// Batch and flush — one request instead of five
const flush = () => {
if (!queue.current.length) return;
const body = JSON.stringify({ metrics: queue.current.splice(0) });
// sendBeacon survives unload and never blocks
const sent = navigator.sendBeacon?.(
'/api/vitals',
new Blob([body], { type: 'application/json' }),
);
if (!sent) {
void fetch('/api/vitals', { method: 'POST', body, keepalive: true });
}
};
onLCP(report);
onINP(report);
onCLS(report);
onFCP(report);
onTTFB(report);
// visibilitychange is the reliable lifecycle event — `unload` breaks bfcache
const onHidden = () => { if (document.visibilityState === 'hidden') flush(); };
document.addEventListener('visibilitychange', onHidden);
window.addEventListener('pagehide', flush);
return () => {
document.removeEventListener('visibilitychange', onHidden);
window.removeEventListener('pagehide', flush);
flush();
};
}, [pageType]);
return null;
}
/** Collapse dynamic segments so /p/abc and /p/xyz aggregate together. */
function normalizePath(pathname: string): string {
return pathname
.replace(/^\/(uk|de|fr|us|ca|au)(?=\/|$)/, '/:market')
.replace(/\/p\/[^/]+/, '/p/:slug')
.replace(/\/c\/[^/]+/, '/c/:slug')
.replace(/\/order\/[^/]+/, '/order/:id')
.replace(/\/[0-9a-f]{8}-[0-9a-f-]{27,}/gi, '/:uuid')
.replace(/\/\d+/g, '/:id');
}
function getDeviceClass(): string {
const mem = (navigator as any).deviceMemory ?? 8;
const cores = navigator.hardwareConcurrency ?? 8;
const mobile = /Mobi|Android|iPhone/i.test(navigator.userAgent);
if (!mobile) return 'desktop';
if (mem <= 2 || cores <= 4) return 'mobile-low';
if (mem <= 4 || cores <= 6) return 'mobile-mid';
return 'mobile-high';
}
// app/p/[slug]/layout.tsx — page type comes from the route, not from parsing the URL
import { WebVitals } from '@/app/components/web-vitals';
export default function ProductLayout({ children }) {
return <>{children}<WebVitals pageType="pdp" /></>;
}
normalizePathis the difference between a usable dataset and 2.4M unique rows. Without it, every SKU is its own dimension value and no aggregation works.
The dimensions that matter¶
| Dimension | Why | Example question it answers |
|---|---|---|
pageType |
Bottlenecks differ per template | "Is the PDP or the PLP worse?" |
deviceClass |
6× CPU difference across the range | "Are low‑end devices failing INP?" |
connection |
Network vs CPU bound | "Is this a bandwidth problem?" |
releaseSha |
Regression attribution | "Which deploy caused this?" |
experiments |
A/B analysis | "Did the treatment get faster?" |
isReturning |
Cache state | "Is this a first‑visit problem?" |
path (normalized) |
Route‑level detail | "Which category page is slow?" |
saveData |
Data‑saver users | "Should we serve a lighter variant?" |
country (server‑derived) |
Geographic latency | "Is Australia being served badly?" |
navigationType |
Load vs back‑forward vs prerender | "Is bfcache working?" |
Dimensions to avoid: raw user agent (unbounded), full URL with query strings (unbounded), user ID (privacy risk and unbounded cardinality). Every high‑cardinality dimension multiplies your storage cost and slows every query.
The collection endpoint¶
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { after } from 'next/server';
const MAX_BATCH = 20;
const VALID_METRICS = new Set(['LCP', 'INP', 'CLS', 'FCP', 'TTFB']);
export async function POST(req: NextRequest) {
let body: { metrics?: unknown[] };
try {
body = await req.json();
} catch {
return new NextResponse(null, { status: 400 });
}
const metrics = Array.isArray(body.metrics) ? body.metrics.slice(0, MAX_BATCH) : [];
// Validate: this endpoint is public and will be abused
const valid = metrics.filter((m: any) =>
m && VALID_METRICS.has(m.name) &&
typeof m.value === 'number' && m.value >= 0 && m.value < 300_000 &&
typeof m.path === 'string' && m.path.length < 200,
);
if (!valid.length) return new NextResponse(null, { status: 204 });
// Server-side enrichment the client can't be trusted with
const country = req.headers.get('x-vercel-ip-country')
?? req.headers.get('cf-ipcountry') ?? 'unknown';
const enriched = valid.map((m: any) => ({ ...m, country, serverTs: Date.now() }));
// Respond immediately; write after the response is sent
after(async () => {
try {
await writeToAnalytics(enriched);
} catch (err) {
// NEVER let analytics failure affect users
console.error('vitals_write_failed', err);
}
});
return new NextResponse(null, { status: 204 });
}
Endpoint rules:
- Return 204 immediately, write asynchronously. The user must never wait on your analytics.
- Validate everything. A public endpoint gets garbage and abuse.
- Cap batch size so one request can't be huge.
- Never throw. A failed write is a lost data point, not a failed request.
- Enrich server‑side with country and timestamp — the client can lie about both.
- Same origin. A third‑party analytics domain gets blocked by ad blockers, costing you 20–40% of your data — and disproportionately the technical users.
- Rate limit by IP if you see abuse.
Sampling¶
At 38M sessions/month, 100% collection is a lot of rows. Sample intelligently:
// Keep 100% of poor experiences — they're what you're trying to fix.
// Sample the good ones.
function shouldSample(metric: VitalsPayload): boolean {
if (metric.rating === 'poor') return true; // always keep
if (metric.pageType === 'checkout') return true; // always keep
return Math.random() < 0.1; // 10% of the rest
}
Store the sampling rate alongside the data so you can weight correctly when computing percentiles. Naively sampling and then computing p75 on the sample biases toward the poor tail you over‑kept.
Custom commerce metrics¶
// Time to Add-to-Cart Ready: the moment the buy button actually works
'use client';
export function AddToCartReadyMarker() {
useEffect(() => {
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
reportCustom('atc_ready', performance.now() - nav.startTime);
}, []);
return null;
}
// lib/report-custom.ts — interaction latency measured to the next paint
export function measureInteraction(name: string, fn: () => void | Promise<void>) {
const t0 = performance.now();
const done = () => {
requestAnimationFrame(() => requestAnimationFrame(() => {
reportCustom(`interaction_${name}`, performance.now() - t0);
}));
};
const result = fn();
if (result instanceof Promise) void result.finally(done);
else done();
}
// Usage
<button onClick={() => measureInteraction('filter_apply', () => applyFilter(facet, value))}>
The commerce custom metric set:
| Metric | Definition |
|---|---|
atc_ready |
Navigation start → add‑to‑cart handler wired |
interaction_filter_apply |
Filter tap → next paint |
interaction_variant_select |
Swatch tap → next paint |
interaction_search_submit |
Submit → results painted |
soft_nav_lcp |
Client‑side route change → largest content painted |
hydration_complete |
HTML received → hydration effect fired |
checkout_step_ready |
Step navigation → form interactive |
Soft navigation timing¶
Standard LCP doesn't fire on client‑side route changes, so a large share of your traffic is unmeasured. Approximate it:
// app/components/soft-nav-timing.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
export function SoftNavTiming() {
const pathname = usePathname();
const first = useRef(true);
const startRef = useRef(0);
useEffect(() => {
if (first.current) { first.current = false; return; } // hard load — real LCP covers it
startRef.current = performance.now();
// Approximate "content painted" for the new route
let observer: PerformanceObserver | null = null;
const raf = requestAnimationFrame(() => requestAnimationFrame(() => {
reportCustom('soft_nav_paint', performance.now() - startRef.current, { path: pathname });
}));
// Also track when the largest image for this route finishes
observer = new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (e.startTime > startRef.current && (e as PerformanceResourceTiming).initiatorType === 'img') {
reportCustom('soft_nav_largest_image', e.startTime + e.duration - startRef.current, { path: pathname });
}
}
});
observer.observe({ type: 'resource', buffered: false });
return () => { cancelAnimationFrame(raf); observer?.disconnect(); };
}, [pathname]);
return null;
}
It's an approximation, not the CWV metric — but it's trackable and it catches regressions in the navigation experience that standard vitals miss entirely.
Dashboards¶
Four views. If you build more, nobody looks at any of them.
1. Executive — one number per metric, trended 90 days, against thresholds, with the % of page views in "good".
p75 Good% 30d trend
LCP (mobile) 2.1 s 78% ▼ 0.4s
INP (mobile) 142 ms 84% ▼ 88ms
CLS (mobile) 0.03 94% ▼ 0.11
2. Engineering — p75 per metric × page type × device class, daily, with release markers on the timeline. This is the one you use every day.
3. Diagnostic — the attribution views:
- LCP sub‑parts as a stacked bar per page type
- Top 20 INP interactionTarget selectors by count × p75
- Top CLS largestShiftTarget selectors
- LCP element identity distribution per page type
4. Business — conversion and revenue per session, bucketed by LCP and INP. This is the one that funds the work (1.1).
-- The engineering view
SELECT DATE(ts) AS day, page_type, device_class,
APPROX_QUANTILES(value, 100)[OFFSET(75)] AS p75,
COUNTIF(rating = 'good') / COUNT(*) AS good_rate,
COUNT(*) AS samples
FROM vitals
WHERE name = 'LCP' AND ts >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
GROUP BY 1, 2, 3
ORDER BY 1 DESC;
Alerts¶
Alert on change, not on absolute thresholds — a static threshold either fires constantly or never.
- alert: LCPRegression
# 20% worse than the 7-day baseline, sustained for an hour
expr: |
(p75_lcp{page_type="pdp",device="mobile"} /
avg_over_time(p75_lcp{page_type="pdp",device="mobile"}[7d])) > 1.20
for: 1h
labels: { severity: page }
annotations:
summary: "PDP mobile LCP p75 regressed >20% vs 7-day baseline"
runbook: "docs/09-operating/03-regression-triage.md"
- alert: INPRegression
expr: p75_inp{device="mobile"} > 250
for: 2h
labels: { severity: ticket }
- alert: VitalsDataMissing
# The failure mode nobody plans for: the reporter broke and you're blind
expr: rate(vitals_received_total[15m]) < 0.5 * rate(vitals_received_total[15m] offset 1d)
for: 30m
labels: { severity: page }
Alert on data volume. The worst failure isn't a regression — it's your RUM silently breaking and nobody noticing for three weeks.
Privacy¶
- No PII. No emails, no user IDs, no full URLs with tokens or query parameters that might contain them.
- Normalize paths before sending — this also removes accidental PII in URL segments.
- Respect consent where required. Performance telemetry is often legitimate‑interest under GDPR, but check with your legal team and gate it if they say so.
- Set a retention policy — 13 months is typical (year‑over‑year comparison plus a month).
- Don't fingerprint.
deviceClassfrom coarse buckets is fine; a detailed hardware profile is not.
Common mistakes¶
| Mistake | Cost |
|---|---|
Using the basic web-vitals build, not attribution |
Numbers without causes |
| Not normalizing paths | Unbounded cardinality; no aggregation |
No releaseSha dimension |
Regression triage takes days instead of minutes |
| Third‑party analytics domain | 20–40% data loss to ad blockers |
| Synchronous reporting | Your telemetry becomes a performance problem |
Flushing on unload |
Breaks bfcache |
| Naive sampling then computing p75 | Biased percentiles |
| No alert on data volume | Silent blindness |
| Too many dashboards | Nobody uses any of them |
Lab 9.1 — Ship RUM¶
- Install the reporter with the attribution build and all core dimensions.
- Verify path normalization — check that your dataset has tens of distinct paths, not millions.
- Build the collection endpoint with validation, async writes, and a 204 response.
- Add
releaseSha— this one dimension saves the most time long term. - Add two custom metrics:
atc_readyand your primary interaction. - Build the four dashboards. Start with the engineering one.
- Set the three alerts, including the data‑volume one.
- Cross‑check against CrUX after a week. If your p75 differs from CrUX's by more than ~15%, investigate — usually sampling bias or a normalization bug.
Checklist¶
-
web-vitals/attributionbuild, all five metrics - Paths normalized before sending
- Dimensions: pageType, deviceClass, connection, releaseSha, experiments, isReturning, country
- Batched, sent with
sendBeacononvisibilitychange - Same‑origin collection endpoint, validated, async, always 204
- Sampling keeps 100% of poor experiences and checkout
- Commerce custom metrics collected
- Soft navigation timing approximated
- Four dashboards, no more
- Alerts on change, plus a data‑volume alert
- No PII; retention policy set