Capturing what the user did before the white screen appeared

A stack trace names the throw site, never the sequence. Record a ring buffer of the last few user actions and ship it with the error — code, limits and cheaper alternatives.

The trace can’t tell you what happened beforehand because nothing recorded it. Cannot read properties of undefined names the line that threw and the frames that called it; the promo code the user applied ninety seconds earlier, the tab they backgrounded, the request that came back 200 with an empty body — none of that is in the exception object, so no tracker can recover it after the fact. You have to buffer it in the client and send it with the report. Infrai’s POST /v1/errors/capture will store the whole trail, but only if you build the trail yourself.

That’s the shape of the fix: a small ring buffer of recent actions, flushed into the error report at the moment of the throw. Below is the buffer, the React wiring, what the store keeps, and an honest comparison against session replay — which is a better answer to this question if you can afford it and your privacy review agrees.

A white screen is two events, not one

React unmounts the tree when a render throws and no error boundary catches it. So the user sees a blank page and reports one symptom, while your data has one exception at the throw site and nothing about the state that made it throw.

Nine times in ten the interesting event is earlier: a fetch that returned {} instead of an object, a route transition that dropped a query parameter, a click on a disabled-looking control that fired twice. The trace points at the corpse. The trail points at the cause.

The ring buffer

Ten slots is plenty — it covers the last few seconds of intent without turning your error payload into a log stream:

const TRAIL = [];
const MAX_CRUMBS = 10;

export function crumb(kind, detail) {
  TRAIL.push({ at: Date.now(), kind, detail: String(detail).slice(0, 80) });
  if (TRAIL.length > MAX_CRUMBS) TRAIL.shift();
}

export function renderTrail(now = Date.now()) {
  return TRAIL.map((c) => `${Math.round((now - c.at) / 1000)}s ${c.kind} ${c.detail}`).join(" > ");
}

document.addEventListener("click", (e) => {
  const el = e.target.closest("button, a, [role=button]");
  if (el) crumb("click", el.id ? `#${el.id}` : el.textContent?.trim() ?? "unnamed");
}, { capture: true });

const originalFetch = window.fetch;
window.fetch = async function instrumentedFetch(input, init) {
  const url = typeof input === "string" ? input : input.url;
  try {
    const res = await originalFetch(input, init);
    crumb("xhr", `${res.status} ${init?.method ?? "GET"} ${new URL(url, location.origin).pathname}`);
    return res;
  } catch (err) {
    crumb("xhr", `failed ${new URL(url, location.origin).pathname}`);
    throw err;
  }
};

window.addEventListener("popstate", () => crumb("nav", location.pathname));

Patching window.fetch is the one piece to think about — it’s how every browser SDK does it, and it means a second library doing the same thing wraps your wrapper. Keep the patch in one module and apply it once.

Wiring it to an error boundary

React 19’s class boundary still owns this job; componentDidCatch is where the trail gets attached:

import { Component } from "react";
import { renderTrail } from "./trail.js";

export class CaptureBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { crashed: false };
  }

  static getDerivedStateFromError() {
    return { crashed: true };
  }

  componentDidCatch(error, info) {
    const component = /at (\w+)/.exec(info.componentStack ?? "")?.[1] ?? "unknown";
    const body = {
      message: `${error.name}: ${error.message} at ${component} | trail: ${renderTrail()}`,
      source: location.pathname,
      kind: "boundary",
    };
    navigator.sendBeacon("/api/client-error", new Blob([JSON.stringify(body)], { type: "application/json" }));
  }

  render() {
    return this.state.crashed ? this.props.fallback : this.props.children;
  }
}

The beacon goes to your own backend, which holds the key and forwards to Infrai — the browser never sees the credential, for reasons the direct-versus-proxy guide sets out with the measured CORS behaviour.

What lands in the store

The forwarded call, with the trail carried in message:

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/errors/capture" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "TypeError: cart.totals is undefined at CheckoutSummary (src/routes/checkout.tsx:64:18) | trail: nav /cart > click #apply-promo > xhr 200 POST /api/promo > click #checkout > render CheckoutSummary",
    "exception": "TypeError",
    "fingerprint": "web:checkout:white-screen:cart-totals",
    "environment": "production",
    "release": "web-2026.07.12"
  }'

Read it back and two things are worth noticing:

curl -sS "https://api.infrai.cc/v1/errors/get/evt_err_cscpfYlcCE0ypl9EA1V1iFwS" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "event_id": "evt_err_cscpfYlcCE0ypl9EA1V1iFwS",
    "title": "TypeError: cart.totals is undefined at CheckoutSummary (src/routes/checkout.tsx:64:18) | trail: nav /cart > click #apply",
    "exception": {
      "type": "Message",
      "value": "TypeError: cart.totals is undefined at CheckoutSummary (src/routes/checkout.tsx:64:18) | trail: nav /cart > click #apply-promo > xhr 200 POST /api/promo > click #checkout > render CheckoutSummary",
      "stacktrace": []
    },
    "breadcrumbs": [],
    "environment": "production",
    "release": "web-2026.07.12"
  }
}

First, title is the first 120 characters of your message and the full text lives on in message — so lead with the error and put the trail after it, or your group list becomes a wall of identical prefixes. Second, stacktrace is empty and the stored exception.type is the literal Message: this API keeps text and groups it, it doesn’t parse frames. The event record shows a breadcrumbs slot, but the documented request body for capture is message, exception, fingerprint, environment and release, so the supported way to ship a trail today is inside message. Check the errors reference before you rely on anything wider than those five fields.

Four ways to reconstruct the lead-up

MethodWhat it showsCost profilePrivacy exposure
Action trail in the error payloadThe last ~10 things the user didA few hundred bytes per eventLow — you choose what gets recorded
Session replay (Sentry, Datadog RUM)A video-like reconstruction of the DOMPriced per replay, and it adds upHigh — masking rules are a permanent chore
Server request logs joined by request idWhat your API saw, not what the user clickedPer GB ingestedMedium
Asking the userSometimes the truth, usually a guessAn afternoonNone

Replay is genuinely better at the ambiguous cases — a CSS regression that makes a control unclickable is invisible in a breadcrumb trail and obvious in a replay. It’s also the option most likely to be blocked by your data protection review, and the one that doesn’t fit if you need something running before lunch. Bugsnag’s breadcrumb model sits in between: a maintained SDK that records the same kind of trail automatically.

Grouping, briefly

Fingerprint by the component and the failing invariant — web:checkout:white-screen:cart-totals — never by the rendered message, which will carry the trail and split into one group per user. Controlling that granularity properly has its own guide.

Cost, and where this stops

Capture is metered at $0.00005 per event, verified 2026-07-26; every read route here is free and rate-limited. A trail adds bytes, not calls, so the price doesn’t move:

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | python3 -c "import json,sys; caps=json.load(sys.stdin)['capabilities']; print([c['billing'] for c in caps if c['id']=='errors.capture'])"

Rates move down over time, so read it live rather than trusting the number above. The trade-off worth stating once: you’re getting a text trail you designed, not a replay, and you maintain the instrumentation yourself. In exchange the trail is exactly as small, as private and as readable as you decide to make it — and the key doing the capture is the same one already carrying your queue, storage and email traffic.

References

Browse more errors developer guides