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
fetchis 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,
}));
}
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.
Deep dives in this guide #
- Building a Structured Error Payload for Crash Reports works through each field, the scrubbing rules, and how to keep the schema stable across releases.
- Deduplicating Error Noise with Fingerprinting and Sampling covers grouping stability across deploys and tuning the sampler against a real incident.
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.
Related #
- Frontend Error Boundary Architecture & Fundamentals — where the reporting contract sits in the wider architecture
- Testing & Fault Injection for Error Boundaries — asserting the reporter is still attached before you ship
- Service Worker Error Telemetry & Debugging — the same problem one layer down, where there is no window object
- Error Propagation Strategies — making sure errors reach a boundary that can report them at all