Skip to content

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" /></>;
}

normalizePath is 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:

  1. Return 204 immediately, write asynchronously. The user must never wait on your analytics.
  2. Validate everything. A public endpoint gets garbage and abuse.
  3. Cap batch size so one request can't be huge.
  4. Never throw. A failed write is a lost data point, not a failed request.
  5. Enrich server‑side with country and timestamp — the client can lie about both.
  6. Same origin. A third‑party analytics domain gets blocked by ad blockers, costing you 20–40% of your data — and disproportionately the technical users.
  7. 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. deviceClass from 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

  1. Install the reporter with the attribution build and all core dimensions.
  2. Verify path normalization — check that your dataset has tens of distinct paths, not millions.
  3. Build the collection endpoint with validation, async writes, and a 204 response.
  4. Add releaseSha — this one dimension saves the most time long term.
  5. Add two custom metrics: atc_ready and your primary interaction.
  6. Build the four dashboards. Start with the engineering one.
  7. Set the three alerts, including the data‑volume one.
  8. 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/attribution build, all five metrics
  • Paths normalized before sending
  • Dimensions: pageType, deviceClass, connection, releaseSha, experiments, isReturning, country
  • Batched, sent with sendBeacon on visibilitychange
  • 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

Next: 9.2 CI gates & synthetics