typescript ruby 102 lines · 3 tabs

Web Vitals reporting to an API endpoint

Shared by codesnips Jan 2026
3 tabs
import { onCLS, onINP, onLCP, onFCP, onTTFB, type Metric } from 'web-vitals';

export interface VitalPayload {
  id: string;
  name: Metric['name'];
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  navigationType: string;
  path: string;
}

const ENDPOINT = '/api/web_vitals';
let buffer: VitalPayload[] = [];

function record(metric: Metric): void {
  buffer.push({
    id: metric.id,
    name: metric.name,
    value: Math.round(metric.value * 100) / 100,
    rating: metric.rating,
    navigationType: metric.navigationType,
    path: window.location.pathname,
  });
}

function flush(): void {
  if (buffer.length === 0) return;
  const body = JSON.stringify({ vitals: buffer });
  buffer = [];

  if (navigator.sendBeacon) {
    const blob = new Blob([body], { type: 'application/json' });
    navigator.sendBeacon(ENDPOINT, blob);
    return;
  }

  // Fallback: keepalive lets the request outlive the page.
  void fetch(ENDPOINT, {
    method: 'POST',
    body,
    keepalive: true,
    headers: { 'Content-Type': 'application/json' },
  });
}

function flushWhenHidden(): void {
  if (document.visibilityState === 'hidden') flush();
}

export function startReporting(): void {
  onCLS(record);
  onINP(record);
  onLCP(record);
  onFCP(record);
  onTTFB(record);

  document.addEventListener('visibilitychange', flushWhenHidden);
  window.addEventListener('pagehide', flush);
}
3 files · typescript, ruby Explain with highlit

This snippet shows how real-user performance metrics (Core Web Vitals) are captured in a React app and shipped reliably to a backend collector. The pattern matters because synthetic lab tests miss the long tail of real devices and networks; sending Web Vitals from actual sessions gives a true distribution of LCP, CLS, INP, FCP, and TTFB.

In reportWebVitals.ts, the web-vitals library callbacks are wrapped so each metric is normalized into a flat VitalPayload with a stable id, the metric name, its value, and a coarse rating. A key detail is that the reporter is buffered rather than firing a request per metric: metrics arrive at different times during a page's life, so flush() batches whatever has accumulated. Sending happens via navigator.sendBeacon, which queues the request in the browser's own queue and survives page unload — a plain fetch would be cancelled when the user navigates away, silently dropping the most important terminal metrics like CLS and INP. A fetch fallback with keepalive covers browsers without beacon support.

The flushWhenHidden wiring listens for visibilitychange and pagehide, the only reliable signals that a session is ending on mobile. Flushing on hidden rather than beforeunload is deliberate, since beforeunload is unreliable on iOS.

In useWebVitals.ts, a custom hook adapts the reporter to React's lifecycle. It runs once via useEffect with an empty dependency array and a didInit ref guard, because React 18 Strict Mode intentionally double-invokes effects in development; without the guard, listeners and metric subscriptions would be registered twice.

The WebVitalsController in Rails receives the batch, treats each entry as append-only telemetry, and uses insert_all for a single bulk write instead of N inserts. It responds 204 No Content because a beacon ignores the response body anyway, and it defensively caps the batch size to avoid a malicious or buggy client flooding the table. rating is validated against the known enum so bad data never reaches aggregation. Together these files form a compact, resilient Real User Monitoring pipeline where the browser's own delivery guarantees carry the data across the fragile moment of page teardown.


Related snips

Share this code

Here's the card — post it anywhere.

Web Vitals reporting to an API endpoint — share card
Link copied