ime, and presentation delay. For CLS, it identifies the exact DOM element causing layout shifts. This metadata transforms abstract scores into actionable debugging targets.
import { onLCP, onINP, onCLS, Metric } from 'web-vitals/attribution';
interface PerformanceSignal {
metricName: string;
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
navigationType: string;
attribution: Record<string, unknown>;
context: DeviceContext;
timestamp: number;
}
function initializeVitalsCollector(): void {
const handler = (metric: Metric) => {
const payload: PerformanceSignal = {
metricName: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
attribution: metric.attribution ?? {},
context: captureDeviceContext(),
timestamp: Date.now(),
};
queuePerformanceTelemetry(payload);
};
onLCP(handler);
onINP(handler);
onCLS(handler);
}
Step 2: Enrich with Runtime Device Context
Raw metrics lack environmental context. A 2.4s LCP means nothing without knowing whether it occurred on a desktop fiber connection or a 3G mobile network. Capture non-standard but widely supported browser APIs to segment data effectively.
interface DeviceContext {
effectiveConnection: string;
memoryClass: 'low' | 'mid' | 'high' | 'unknown';
userAgentBucket: string;
}
function captureDeviceContext(): DeviceContext {
const nav = navigator as any;
const conn = nav.connection?.effectiveType ?? 'unknown';
let memBucket: DeviceContext['memoryClass'] = 'unknown';
if (typeof nav.deviceMemory === 'number') {
memBucket = nav.deviceMemory <= 2 ? 'low' : nav.deviceMemory <= 4 ? 'mid' : 'high';
}
return {
effectiveConnection: conn,
memoryClass: memBucket,
userAgentBucket: classifyBrowser(nav.userAgent),
};
}
function classifyBrowser(ua: string): string {
if (/Chrome/.test(ua) && !/Edg/.test(ua)) return 'chrome';
if (/Safari/.test(ua) && !/Chrome/.test(ua)) return 'safari';
if (/Firefox/.test(ua)) return 'firefox';
return 'other';
}
Step 3: Implement Reliable Transmission with Sampling
Sending every metric to your backend creates storage bloat and network overhead. Implement a sampling strategy that prioritizes poor-rated metrics while maintaining statistical validity for good/neutral scores. Use keepalive: true or navigator.sendBeacon to prevent data loss during page transitions.
const TELEMETRY_ENDPOINT = '/api/performance/ingest';
const SAMPLE_RATE = { good: 0.1, needsImprovement: 0.5, poor: 1.0 };
function queuePerformanceTelemetry(signal: PerformanceSignal): void {
const rate = SAMPLE_RATE[signal.rating] ?? 0.2;
if (Math.random() > rate) return;
const payload = JSON.stringify(signal);
if (navigator.sendBeacon) {
navigator.sendBeacon(TELEMETRY_ENDPOINT, payload);
} else {
fetch(TELEMETRY_ENDPOINT, {
method: 'POST',
body: payload,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
}).catch(() => {});
}
}
Step 4: Track Interaction-Level INP Data
INP reports the single worst interaction per page load. While useful for scoring, it obscures the long tail of problematic components. Collect individual interactions that exceed the 200ms threshold to identify recurring UI bottlenecks.
function trackSlowInteractions(): void {
onINP((metric) => {
const attr = metric.attribution as any;
if (!attr?.interactionTarget) return;
const interactionPayload = {
eventType: attr.interactionType,
targetSelector: attr.interactionTarget,
duration: metric.value,
phaseBreakdown: {
inputDelay: attr.inputDelay,
processingTime: attr.processingTime,
presentationDelay: attr.presentationDelay,
},
pagePath: location.pathname,
};
if (metric.value > 200) {
queuePerformanceTelemetry({
metricName: 'INP_INTERACTION',
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
attribution: interactionPayload,
context: captureDeviceContext(),
timestamp: Date.now(),
});
}
});
}
Architecture Rationale
- Attribution over raw values: Raw scores tell you that performance degraded. Attribution tells you where and why. INP phase breakdowns reveal whether the main thread is blocked by JavaScript or waiting for style recalculation.
- Sampling strategy: Transmitting 100% of "good" metrics wastes bandwidth and storage. Weighted sampling preserves statistical accuracy while reducing payload volume by ~60-70%.
- Dual transmission fallback:
sendBeacon is preferred for reliability, but fetch with keepalive ensures compatibility across older browser versions.
- Interaction-level INP tracking: Session-level INP masks component-specific issues. Tracking individual slow interactions surfaces recurring selectors (e.g.,
#product-grid, .checkout-btn) that require targeted optimization.
Pitfall Guide
| Pitfall | Explanation | Fix |
|---|
| Averaging Percentiles | Mean LCP/INP values hide tail latency. A 1.8s average can mask 30% of users experiencing >4s loads. | Track p75 and p95 exclusively. Use p75 for alerting thresholds and p95 for capacity planning. |
| Ignoring Attribution Payload Size | metric.attribution can contain full DOM paths, event listeners, and layout trees, inflating payloads to 50KB+. | Truncate CSS selectors to 3 segments, hash long identifiers, and strip non-essential DOM attributes before transmission. |
| Missing Unload Reliability | Metrics fired during pagehide or visibilitychange are dropped if the fetch request is cancelled. | Always use keepalive: true or navigator.sendBeacon. Never rely on standard fetch without these flags. |
| Alerting on Raw Single-Event Spikes | One user on a congested network triggers a p75 alert, causing alert fatigue and false positives. | Implement a rolling 15-minute window with a minimum sample size (e.g., 50 events) before firing threshold alerts. |
| Assuming Chrome-Only APIs Are Universal | navigator.connection and navigator.deviceMemory are non-standard and return undefined in Firefox/Safari. | Treat unknown as a valid segmentation bucket. Do not block instrumentation or throw errors when these APIs are absent. |
| Collecting Every INP Interaction | Storing all interactions bloats databases and complicates analysis. | Only transmit interactions exceeding 200ms. Aggregate counts for sub-threshold events in the backend. |
| Static Performance Budgets | Applying identical thresholds across desktop, mobile, 4G, and 3G creates unrealistic targets. | Segment budgets by device class and connection type. Allow higher thresholds for constrained environments while maintaining p75 targets. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Low traffic (<10k monthly sessions) | Rely on CrUX via PSI/GSC | Insufficient volume for statistically significant RUM; CrUX aggregates enough data | $0 infrastructure cost |
| Medium traffic (10k-500k sessions) | Hybrid: CrUX + sampled RUM | CrUX covers baseline; RUM provides attribution and real-time regression detection | Low ($50-150/mo storage + compute) |
| High traffic (>500k sessions) | Full RUM pipeline with p75 alerting | CrUX lag and aggregation mask regional/device-specific regressions; RUM enables precise optimization | Moderate ($200-500/mo ingestion + analytics) |
| E-commerce / Conversion-critical | RUM with interaction-level INP tracking | Slow interactions directly impact checkout completion; attribution pinpoints UI bottlenecks | High ROI justifies infrastructure spend |
Configuration Template
// perf.config.ts
export const PerformanceConfig = {
endpoints: {
ingest: '/api/performance/ingest',
health: '/api/performance/health',
},
thresholds: {
LCP: { good: 2500, needsImprovement: 4000 },
INP: { good: 200, needsImprovement: 500 },
CLS: { good: 0.1, needsImprovement: 0.25 },
},
sampling: {
good: 0.1,
needsImprovement: 0.5,
poor: 1.0,
},
alerting: {
windowMinutes: 15,
minSampleSize: 50,
percentile: 75,
channels: ['slack', 'pagerduty'],
},
retention: {
rawDays: 7,
aggregatedDays: 90,
},
deviceContext: {
enableConnectionType: true,
enableMemoryClass: true,
fallbackBucket: 'unknown',
},
};
Quick Start Guide
- Install the attribution build:
npm install web-vitals and import from web-vitals/attribution to access phase breakdowns and shift sources.
- Initialize collectors: Call
initializeVitalsCollector() and trackSlowInteractions() in your application entry point after hydration or DOMContentLoaded.
- Deploy ingestion endpoint: Create a lightweight API route that accepts JSON payloads, validates structure, and writes to a time-series database or analytics warehouse.
- Configure p75 alerting: Set up a scheduled job that queries the last 15 minutes of data, calculates p75 per metric, and triggers notifications when thresholds are breached with sufficient sample size.
- Validate in staging: Use Chrome DevTools Network throttling and device emulation to verify payload structure, sampling rates, and alert routing before production rollout.