This guide completes the reporting half of the contract described in Frontend Error Boundary Architecture & Fundamentals: a boundary that contains a crash but never tells you it happened has converted a loud failure into a silent one.

The problem: contained crashes are invisible by construction #

The purpose of an error boundary is to stop a crash from reaching the user. The side effect is that it also stops the crash from reaching you. Once a boundary is in place, the browser console of the affected user is the only remaining evidence — and nobody is reading it. Teams discover this the same way every time: a support ticket says “the price box is empty on mobile”, the crash dashboard shows nothing for that release, and it turns out the boundary has been quietly catching a toLocaleString failure on one locale for six weeks.

Getting the report out of the browser is not the hard part; fetch can do that. The hard parts are the four that follow:

  • Fidelity. A message without a component stack, a route, a release and a boundary scope produces a dashboard full of “TypeError: undefined is not an object” with nowhere to go.
  • Volume. A component that throws during render can throw on every re-render. One user in a retry loop can generate five-figure event counts in a minute.
  • Delivery. The most valuable reports come from crashes that lead to the user leaving the page. A normal fetch is cancelled when the document unloads.
  • Privacy. The context that would be most useful — props, form state, storage — is exactly the context that must never leave the device.

The pipeline below addresses these in order, and every stage is designed to be asserted against in the suite described in Testing & Fault Injection for Error Boundaries.

Prerequisites #


Core implementation: one payload, one reporter #

Everything downstream — grouping, alerting, dashboards — depends on every crash arriving in the same shape. Define the shape once, in types, and let the compiler prevent drift.

// crash-payload.ts
export interface CrashPayload {
  /** Grouping key computed on the client so the server never guesses. */
  fingerprint: string;
  /** Which boundary caught it — 'pricing', 'dashboard-panel', 'root'. */
  scope: string;
  /** Route pattern, never the resolved URL: /orders/:id, not /orders/8814. */
  route: string;
  release: string;
  name: string;
  message: string;
  /** Bundle frames; the backend rewrites them with uploaded source maps. */
  stack?: string;
  /** React element path — which UI was on screen, not which function ran. */
  componentStack?: string;
  /** Did the user get a working page back? Filled in after recovery. */
  recovery: 'fallback-shown' | 'retry-succeeded' | 'retry-failed' | 'reloaded';
  session: {
    id: string;
    /** Nth crash in this session — a cheap loop detector. */
    sequence: number;
    startedAt: number;
  };
  device: {
    viewport: `${number}x${number}`;
    online: boolean;
    /** Coarse only: 'chrome/126', never a full user-agent string. */
    agent: string;
  };
  ts: number;
}

const SESSION_ID = crypto.randomUUID();
const SESSION_START = Date.now();
let sequence = 0;

export function buildPayload(
  err: Error,
  ctx: { scope: string; route: string; componentStack?: string },
): CrashPayload {
  sequence += 1;
  return {
    fingerprint: fingerprintOf(err, ctx.scope),
    scope: ctx.scope,
    route: ctx.route,
    release: __RELEASE__,
    name: err.name,
    message: scrub(err.message),
    stack: err.stack?.split('\n').slice(0, 20).join('\n'),
    componentStack: ctx.componentStack?.split('\n').slice(0, 12).join('\n'),
    recovery: 'fallback-shown',
    session: { id: SESSION_ID, sequence, startedAt: SESSION_START },
    device: {
      viewport: `${window.innerWidth}x${window.innerHeight}`,
      online: navigator.onLine,
      agent: coarseAgent(),
    },
    ts: Date.now(),
  };
}

/** Strip anything that looks like an identifier, token, email or path segment. */
function scrub(text: string): string {
  return text
    .replace(/[\w.+-]+@[\w-]+\.[\w.]+/g, '[email]')
    .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '[uuid]')
    .replace(/\b\d{6,}\b/g, '[id]')
    .replace(/(token|key|secret|password)=[^&\s]+/gi, '$1=[redacted]');
}

The boundary’s only job is to fill in the context it uniquely knows — its own scope and the component stack — and hand the payload to the reporter.

// AppBoundary.tsx (excerpt)
componentDidCatch(error: Error, info: React.ErrorInfo): void {
  // componentStack is available HERE and nowhere else: React discards the
  // failed fiber tree immediately after this callback returns.
  report(buildPayload(error, {
    scope: this.props.scope,
    route: this.props.routePattern,
    componentStack: info.componentStack ?? undefined,
  }));
}
From caught error to stored crash report A left-to-right pipeline: boundary catch, build payload, fingerprint, sampler. The sampler has two outputs: dropped counts that are aggregated locally, and accepted reports that go to the delivery queue. The delivery queue sends via sendBeacon; refused payloads fall back to local storage and are retried on the next page load. boundary catch + component stack build payload scrub + stamp fingerprint name + frames sampler per-fingerprint cap dropped → local counter flushed as one aggregate delivery queue sendBeacon, 64 KB budget refused → localStorage retried next page load

Architecture note: fingerprint on the client, not the server #

Grouping decides whether a dashboard is useful. Server-side grouping heuristics have to guess from a minified stack and a message that may contain a different order id every time; the client knows the boundary scope, the route pattern and the release, and can normalise the message before hashing.

A workable fingerprint is the error name, the message with all numbers and identifiers replaced by placeholders, the top two non-framework stack frames, and the boundary scope. Including the scope is what separates “the formatter throws in the checkout summary” from “the formatter throws in the admin export” — same function, different incident, different owner.

// fingerprint.ts
const FRAMEWORK = /node_modules|react-dom|webpack|vite\/deps/;

export function fingerprintOf(err: Error, scope: string): string {
  const normalised = err.message
    .replace(/\d+/g, 'N')
    .replace(/'[^']*'/g, "'S'")
    .replace(/"[^"]*"/g, '"S"')
    .trim()
    .slice(0, 120);

  const frames = (err.stack ?? '')
    .split('\n')
    .slice(1)
    .map((line) => line.trim())
    .filter((line) => line && !FRAMEWORK.test(line))
    .slice(0, 2)
    .map((line) => line.replace(/:\d+:\d+/g, ''));  // drop line/col churn

  return hash32([err.name, normalised, scope, ...frames].join('|'));
}

/** FNV-1a — deterministic, dependency-free, good enough for grouping. */
function hash32(input: string): string {
  let h = 0x811c9dc5;
  for (let i = 0; i < input.length; i += 1) {
    h ^= input.charCodeAt(i);
    h = Math.imul(h, 0x01000193);
  }
  return (h >>> 0).toString(16).padStart(8, '0');
}

Dropping line and column numbers from frames matters more than it looks: without it, every deploy that shifts a file by one line produces a brand-new “issue” and your regression history resets to zero.

Controlling volume: sampling and the loop guard #

A component that throws during render throws again on every re-render attempt. Without a guard, a single user can emit thousands of identical reports before they close the tab.

// sampler.ts
const FULL_REPORTS_PER_FINGERPRINT = 3;   // send these in full…
const SESSION_HARD_CAP = 25;              // …and never exceed this in total
const seen = new Map<string, number>();
let sent = 0;

export type SampleDecision =
  | { send: true }
  | { send: false; reason: 'duplicate' | 'session-cap'; occurrences: number };

export function decide(fingerprint: string): SampleDecision {
  const count = (seen.get(fingerprint) ?? 0) + 1;
  seen.set(fingerprint, count);

  if (sent >= SESSION_HARD_CAP) {
    return { send: false, reason: 'session-cap', occurrences: count };
  }
  if (count > FULL_REPORTS_PER_FINGERPRINT) {
    // Powers of two thereafter: 4th, 8th, 16th… keeps the shape of a loop
    // visible without paying for every iteration.
    if ((count & (count - 1)) !== 0) {
      return { send: false, reason: 'duplicate', occurrences: count };
    }
  }
  sent += 1;
  return { send: true };
}

export function occurrenceCounts(): Record<string, number> {
  return Object.fromEntries(seen);
}

The power-of-two escalation is deliberate. A flat “first three only” rule tells you a bug exists but not that it is looping; sending the 4th, 8th, 16th and 32nd occurrence costs almost nothing and makes runaway re-render loops obvious in the dashboard, which in turn is what tells you the reset protocol is failing rather than the component.

Volume control What it protects Cost when wrong
Per-fingerprint cap Ingestion bill and dashboard signal-to-noise Under-counted impact if occurrences are not attached
Session hard cap Your own endpoint during a bad deploy Late crashes in a long session go unreported
Power-of-two escalation Visibility into render loops Slightly noisier issues for genuinely frequent bugs
Aggregate flush on unload Accurate occurrence totals Lost counts if the flush is a plain fetch

Delivery that survives the page unloading #

The crashes that matter most are often followed immediately by the user leaving. fetch is cancelled on unload; navigator.sendBeacon is not, but it can refuse a payload that exceeds the browser’s queue budget.

// deliver.ts
const ENDPOINT = '/_crash';
const RETRY_KEY = 'crash_retry_v1';

export function deliver(payload: CrashPayload): void {
  const body = JSON.stringify(payload);

  // text/plain avoids a CORS preflight that would never complete during unload.
  const blob = new Blob([body], { type: 'text/plain;charset=UTF-8' });
  const queued = navigator.sendBeacon?.(ENDPOINT, blob) ?? false;

  if (!queued) persistForRetry(body);
}

function persistForRetry(body: string): void {
  try {
    const pending: string[] = JSON.parse(localStorage.getItem(RETRY_KEY) ?? '[]');
    pending.push(body);
    // Bound the queue: a crash storm must not fill the origin's quota, which
    // would break session recovery for the rest of the app.
    localStorage.setItem(RETRY_KEY, JSON.stringify(pending.slice(-10)));
  } catch {
    // QuotaExceededError — dropping telemetry is always preferable to
    // starving the recovery snapshot of storage.
  }
}

/** Call once on startup, after first paint. */
export function flushRetryQueue(): void {
  let pending: string[] = [];
  try {
    pending = JSON.parse(localStorage.getItem(RETRY_KEY) ?? '[]');
    localStorage.removeItem(RETRY_KEY);
  } catch {
    return;
  }
  for (const body of pending) {
    void fetch(ENDPOINT, { method: 'POST', body, keepalive: true }).catch(() => {
      persistForRetry(body);   // still offline — try again next load
    });
  }
}

/** Flush occurrence aggregates while the page is going away. */
export function installUnloadFlush(): void {
  document.addEventListener('visibilitychange', () => {
    if (document.visibilityState !== 'hidden') return;
    const counts = occurrenceCounts();
    if (Object.keys(counts).length === 0) return;
    navigator.sendBeacon?.(
      `${ENDPOINT}/aggregate`,
      new Blob([JSON.stringify({ session: SESSION_ID, counts })], { type: 'text/plain' }),
    );
  });
}

Note the retry queue deliberately competes for storage with nothing important: it is capped at ten entries and it fails silently on QuotaExceededError, because losing a crash report is strictly better than evicting the user’s recovery snapshot — the trade-off spelled out in Handling QuotaExceededError When Persisting Session State.

Use visibilitychange rather than unload or beforeunload. Mobile Safari and Chrome on Android frequently discard pages without ever firing the legacy unload events, and both browsers treat unload handlers as a reason to skip the back/forward cache entirely — costing you navigation performance in exchange for a handler that does not reliably run.

Which delivery mechanism survives the page going away Three timelines share a vertical marker labelled document discarded. The fetch timeline stops at the marker and is labelled cancelled in flight. The sendBeacon timeline crosses the marker and is labelled queued by the user agent. The storage retry timeline stops before the marker and resumes after it on the next load. document discarded fetch() cancelled in flight sendBeacon() queued by the user agent storage retry written replayed next load Scrubbing happens before serialisation, not after A raw message box containing an email address, a long numeric id and a token parameter flows through a rules box listing email, uuid, long-digit and token patterns, producing a scrubbed message box in which each value has been replaced by a placeholder. A note states that scrubbing lives in the payload builder so no code path can bypass it. raw message "[email protected] order 881442 token=abc123" scrub rules email · uuid · long digits token= · card-shaped scrubbed message "[email] order [id] token=[redacted]" the rules run inside buildPayload, so no reporting path can skip them server-side scrubbing is a second line of defence, never the first

Deep dives in this guide #

Retention and access, decided before the first report #

Every field in the payload is a small, permanent commitment: someone will query it, alert on it, and eventually export it. Deciding retention and access up front is cheaper than retrofitting them after the dashboard has become load-bearing. A workable default is a short window at full fidelity — two to four weeks of complete payloads for debugging — followed by a longer window of aggregates only: fingerprint, counts, affected sessions, release. That keeps trend analysis possible without holding stack traces and breadcrumbs indefinitely.

Frequently asked questions #

Why do my production stack traces show minified function names?

Because the browser reports frames from the shipped bundle. Upload source maps to your reporting backend at build time and keep them off the public origin, so the backend rewrites frames server-side without exposing your unminified source.

Should the frontend send every caught error?

No. A render loop can emit thousands of identical errors per minute per user, which costs money, buries rarer failures, and can take down your own ingestion endpoint. Fingerprint, count per session, send the first few in full, then send periodic aggregates.

What is the difference between a component stack and a JavaScript stack?

The JavaScript stack names the functions that were executing; the component stack names the React elements that were rendering. Triage needs both — the same utility throwing under a checkout form and under an admin table are different incidents with different owners.

How do I stop personal data leaking into crash reports?

Never serialise props, form values, or storage contents. Send stable identifiers — boundary scope, route pattern rather than resolved URL, hashed user id — and scrub query strings and free text before they leave the client.

Does sendBeacon guarantee delivery?

No. It guarantees the request is queued without blocking unload, but it can refuse payloads over the browser’s queue budget (roughly 64 KB) and tells you nothing about the response. Check the return value and persist refused payloads for retry.