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
| Method | What it shows | Cost profile | Privacy exposure |
|---|---|---|---|
| Action trail in the error payload | The last ~10 things the user did | A few hundred bytes per event | Low — you choose what gets recorded |
| Session replay (Sentry, Datadog RUM) | A video-like reconstruction of the DOM | Priced per replay, and it adds up | High — masking rules are a permanent chore |
| Server request logs joined by request id | What your API saw, not what the user clicked | Per GB ingested | Medium |
| Asking the user | Sometimes the truth, usually a guess | An afternoon | None |
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.