This page details the payload contract summarised in Error Telemetry & Crash Reporting, which covers the surrounding pipeline — fingerprinting, sampling and delivery.

The exact problem #

A crash dashboard fills up with entries that read TypeError: Cannot read properties of undefined (reading 'toFixed') at chunk-4f2a.js:1:88213, four hundred times, from an unknown route. Every one of them is technically a correct report and none of them is actionable. The engineer on call cannot tell which feature broke, whether users recovered, or whether this is one loop or four hundred people.

The payload is where triage is won or lost, and the design rule is simple: enumerate the questions someone will ask at 2am, then include exactly the fields that answer them.

Question in the first two minutes Field that answers it
What broke, in product terms? scope — the boundary’s name
Where was the user? route — the matched pattern, not the URL
Is this new? release plus fingerprint
How bad is it for users? recovery outcome, and unique session.id count
Is it looping? session.sequence and occurrence counts
Can I reproduce it? componentStack, breadcrumbs, viewport, online state

Zero-to-working payload #

// payload.ts
export const PAYLOAD_SCHEMA = 3;

export type Recovery = 'fallback-shown' | 'retry-succeeded' | 'retry-failed' | 'reloaded';

export interface Breadcrumb {
  t: number;                                   // ms since session start, not wall clock
  kind: 'nav' | 'request' | 'interaction' | 'connectivity';
  label: string;                               // '/orders/:id', 'POST /api/cart 500'
}

export interface CrashPayload {
  schema: number;
  fingerprint: string;
  scope: string;
  route: string;
  release: string;
  error: {
    name: string;
    message: string;
    stack?: string;
    componentStack?: string;
    /** Present when the failure came from a rejection, not a render throw. */
    async: boolean;
  };
  recovery: Recovery;
  session: { id: string; user?: string; sequence: number; startedAt: number };
  context: {
    viewport: string;
    online: boolean;
    agent: string;
    /** Bounded ring buffer — most recent last. */
    breadcrumbs: Breadcrumb[];
  };
  ts: number;
}

const MAX_STACK_FRAMES = 20;
const MAX_COMPONENT_FRAMES = 12;
const MAX_BREADCRUMBS = 20;
const MAX_MESSAGE = 300;

export function buildPayload(input: {
  error: Error;
  scope: string;
  route: string;
  componentStack?: string;
  async?: boolean;
  recovery?: Recovery;
}): CrashPayload {
  const { error, scope, route } = input;
  return {
    schema: PAYLOAD_SCHEMA,
    fingerprint: fingerprintOf(error, scope),
    scope,
    route,                                     // '/orders/:id' — never the resolved path
    release: __RELEASE__,
    error: {
      name: error.name || 'Error',
      message: scrub(error.message).slice(0, MAX_MESSAGE),
      stack: trimLines(error.stack, MAX_STACK_FRAMES),
      componentStack: trimLines(input.componentStack, MAX_COMPONENT_FRAMES),
      async: input.async ?? false,
    },
    recovery: input.recovery ?? 'fallback-shown',
    session: sessionInfo(),
    context: {
      viewport: `${window.innerWidth}x${window.innerHeight}`,
      online: navigator.onLine,
      agent: coarseAgent(),                    // 'chrome/126 · android'
      breadcrumbs: recentBreadcrumbs(MAX_BREADCRUMBS),
    },
    ts: Date.now(),
  };
}

function trimLines(text: string | undefined, max: number): string | undefined {
  if (!text) return undefined;
  return text.split('\n').slice(0, max).join('\n');
}
Payload fields grouped by the triage question they answer Four stacked blocks. Identity contains fingerprint, scope, route and release and answers what broke and where. Error contains name, message, stack, component stack and async flag and answers why. Impact contains recovery outcome, session id and sequence and answers how bad. Context contains viewport, online state, agent and breadcrumbs and answers can I reproduce it. identity fingerprint · scope · route · release answers: what broke, where, and is it new route is a pattern: /orders/:id error name · message · stack · componentStack answers: which code, which UI, sync or async stack trimmed to 20 frames impact recovery · session.id · session.sequence answers: how many users, did they recover sequence exposes render loops context viewport · online · agent · breadcrumbs answers: can I reproduce this breadcrumbs capped at 20 entries

Step-by-step #

  1. Version the schema. PAYLOAD_SCHEMA lets the backend reject or migrate reports from bundles that are weeks old — which will exist, because users keep tabs open. Without it, a renamed field silently becomes undefined in your queries.
  2. Report the route pattern. /orders/:id groups; /orders/8814 does not, and it exports a customer identifier off-device. Read the pattern from your router’s matched route, not from location.pathname.
  3. Separate error.async. A rejection routed into a boundary and a render throw need different fixes. One boolean saves a triage step every time.
  4. Record recovery, then update it. Send fallback-shown at catch time; if the user retries successfully, send a small follow-up event keyed by fingerprint and session. Severity should track “users who never got the feature back”, not raw error counts.
  5. Bound everything. Twenty stack frames, twelve component frames, twenty breadcrumbs, 300-character message. These caps are what keep the payload inside sendBeacon’s queue budget.
  6. Scrub free text at the boundary of serialisation. Not in the reporter, not on the server — in the builder, so no code path can bypass it.
Where the payload budget actually goes Horizontal bars for five payload sections. Stack frames and breadcrumbs are the largest, identity and context are small, and the total sits below a dashed line marking the eight kilobyte target, well under the sixty-four kilobyte sendBeacon ceiling. stack (20 frames) ~2.6 KB breadcrumbs (20) ~1.9 KB component stack ~1.2 KB message + identity ~0.5 KB device context ~0.2 KB 8 KB target trimming frames and breadcrumbs is what keeps a report inside sendBeacon's queue budget

Scrubbing rules that survive review #

// scrub.ts
const RULES: Array<[RegExp, string]> = [
  [/[\w.+-]+@[\w-]+\.[\w.]+/g, '[email]'],
  [/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '[uuid]'],
  [/\b(?:\d[ -]?){13,19}\b/g, '[pan]'],                    // card-shaped digits
  [/\b(?:eyJ[\w-]+\.){2}[\w-]+\b/g, '[jwt]'],
  [/(token|key|secret|password|authorization)=[^&\s]+/gi, '$1=[redacted]'],
  [/\b\d{6,}\b/g, '[id]'],
];

export function scrub(text: string): string {
  return RULES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
}

/** Breadcrumb labels get the same treatment plus URL normalisation. */
export function scrubLabel(label: string): string {
  return scrub(label.replace(/\/[0-9a-f-]{6,}/gi, '/:id').replace(/\?.*$/, '?…'));
}

Two rules matter more than the rest. Card-shaped digit runs get caught before anything else can log them, and query strings are dropped wholesale rather than filtered key by key — an allowlist is the only safe way to keep a query parameter, and in practice nobody maintains one.

Field Safe to send Reason
scope, route pattern, release Yes Stable, non-identifying, essential for grouping
Hashed user id Yes, with a rotating salt Answers “one user or many” without storing identity
Raw location.href No Carries identifiers, tokens and search terms
Component props or form values No Arbitrary user content, unbounded size
localStorage contents No Often holds tokens and drafts
Full user-agent string Only coarsened High-entropy fingerprinting surface for little triage value
Three fields, before and after the safety rules Two columns. The left column lists unsafe raw values: a resolved order URL, an error message containing an email address, and a full user-agent string. The right column lists their safe replacements: a route pattern, a scrubbed message, and a coarse browser and platform label. what the browser gives you what the payload carries /orders/8814?ref=email-jul /orders/:id "no address for [email protected]" "no address for [email]" Mozilla/5.0 (…) Chrome/126.0.6478.127 chrome/126 · android

Verification #

  1. Serialise a worst-case payload and measure it. Build one with a maximal stack, a full breadcrumb buffer and a long message; new Blob([JSON.stringify(p)]).size must sit under your 8 KB target.
  2. Feed the scrubber a hostile string. Assert an email, a UUID, a card-shaped number, a JWT and a token= parameter are all replaced. Keep that test next to the rules so a future contributor sees it.
  3. Check the route field on a dynamic page. Load /orders/8814, force a crash, and confirm the payload reads /orders/:id. This is the field that most often regresses when routing changes.
  4. Query your own dashboard. Group the last week by fingerprint and confirm one product bug is one row. If a single bug spreads across many rows, the fingerprint inputs — not the payload — need attention; see Deduplicating Error Noise with Fingerprinting and Sampling.
  5. Assert the payload in a test. The reporter assertion in Unit Testing an Error Boundary with React Testing Library is where a dropped componentStack gets caught before release.

Frequently asked questions #

How large should a crash payload be?

Target under 8 KB, hard-cap at 32 KB. sendBeacon can refuse payloads near the browser’s roughly 64 KB queue budget, and a refused report is a lost report. Twenty stack frames and twenty breadcrumbs usually land well inside.

Should I include the user’s id in a crash report?

Include a salted hash, not the raw id or an email. It answers “one user or many” and correlates with support tickets without turning the dashboard into a personal-data store.

Do I need breadcrumbs if I already have a stack trace?

Yes, for what the stack cannot explain: the previous route, a request that failed just before, a connectivity change. Keep them as a bounded ring buffer of typed events, not free-form log lines.