Attaching Breadcrumbs Before an Error Is Thrown
You already record breadcrumbs, yet every error in your dashboard arrives with an empty trail, a stale one from a previous page, or crumbs that were added after the throw. This how-to fixes the timing bug: the trail must be snapshotted synchronously at the exact catch point and frozen onto the error before any await runs. It sits under Structured Error Logging & Breadcrumbs, which builds the full logging envelope, and the broader capture surface in Core JavaScript Error Handling & Boundaries. If your crumbs also lose their causal ordering across await points, pair this with Async Stack Traces & Error Cause Chains.
Symptom / Trigger
The error is captured correctly, but the breadcrumb array attached to it is wrong. You see one of three shapes in the stored record: an empty list, a list identical across unrelated errors, or a list whose newest entries describe events that happened after the failure. A typical record looks like this:
{
"error": { "name": "TypeError", "message": "cart.items is undefined" },
"correlationId": "b1f2-9ac3",
"breadcrumbs": []
}
Or worse, the trail is populated but with the wrong events — crumbs recorded while the async transport was already in flight:
Uncaught (in promise) TypeError: cart.items is undefined
at renderCart (checkout.js:88:19)
at async flushError (report.js:41:5) // <-- crumbs read HERE, one tick too late
at async HTMLButtonElement. (checkout.js:120:7)
By the time flushError awaits and finally reads the shared buffer, a route change or a follow-up fetch has already pushed newer entries and dropped the ones that actually explain the crash.
What makes this class of bug so easy to miss is that everything works in a quiet test. When you throw an error by hand in an idle tab and inspect the record, the buffer has not moved between the throw and the read, so the trail looks perfect. The corruption only surfaces under real traffic, when a click that triggers a failure is immediately followed by more clicks, a navigation, and a burst of background requests — all of which write to the same buffer while your transport is still mid-flight. That is why the symptom is intermittent and correlates with busy sessions: the busier the page, the more the live buffer has drifted by the time it is finally serialized.
Root Cause Explanation
Breadcrumbs live in a single mutable buffer that keeps changing. An Error object, by contrast, is a frozen instant. The bug is that you connect the two by reference instead of by value: you hand the transport a pointer to the live buffer, and by the time that transport serializes it — often after an await — the buffer no longer describes the moment of the throw. The window between “error caught” and “buffer read” is where the trail rots.
// BROKEN: the record holds a live reference to a buffer that keeps mutating
async function report(error) {
const record = { error, breadcrumbs: trail.buffer }; // reference, not a copy
await fetch('/ingest', { method: 'POST', body: JSON.stringify(record) });
// between the await above and the fetch actually reading `record`,
// trail.buffer has already gained new crumbs and shifted old ones out
}
The fix has nothing to do with recording more crumbs. It is about when and how you detach a copy from the live buffer: synchronously, at the catch point, before control ever yields to the event loop. The stages below trace where the trail rots when the copy is deferred.
Step-by-Step Fix
- Build a fixed-capacity ring buffer that overwrites the oldest slot in place.
// ring.js — O(1) writes, fixed memory, newest overwrites oldest
export class BreadcrumbRing {
constructor(capacity = 30) {
this.capacity = capacity;
this.slots = new Array(capacity); // pre-allocated, never grows
this.next = 0; // write cursor
this.count = 0; // how many slots are populated
}
add(category, message, data = {}) {
this.slots[this.next] = { t: Date.now(), category, message, data };
this.next = (this.next + 1) % this.capacity; // wrap around
this.count = Math.min(this.count + 1, this.capacity);
}
}
- Record crumbs synchronously from event sources — never inside an async callback that can reorder.
// instrument.js — each source writes one small, primitive-only crumb
import { BreadcrumbRing } from './ring.js';
export const trail = new BreadcrumbRing(30);
window.addEventListener('popstate', () =>
trail.add('navigation', 'popstate', { to: location.pathname })); // sync write
document.addEventListener('click', (e) => {
const el = e.target.closest('[data-track]');
if (el) trail.add('ui', 'click', { target: el.dataset.track }); // label only
});
- Snapshot the ring into a chronological, deep-copied array at the moment of capture.
// ring.js (continued) — return oldest -> newest, fully detached from live slots
snapshot() {
const out = [];
for (let i = 0; i < this.count; i++) {
const idx = (this.next - this.count + i + this.capacity) % this.capacity;
const c = this.slots[idx];
out.push({ ...c, data: { ...c.data } }); // copy so later writes can't mutate it
}
return out;
}
- Take the snapshot in the catch block itself, before any
await, then freeze it onto the record.
// capture.js — the snapshot happens on the SAME synchronous tick as the catch
export function captureError(error) {
const breadcrumbs = trail.snapshot(); // detached copy, taken now — not later
const record = Object.freeze({ error, breadcrumbs, at: Date.now() });
void ship(record); // async transport receives an immutable, correct trail
return record;
}
- Hand the frozen record to the async transport — the network step reads a value that can no longer change.
// capture.js (continued) — the await lives here, AFTER the snapshot is frozen
async function ship(record) {
// record.breadcrumbs was copied on the caller's tick; nothing mutates it now
await fetch('/ingest', { method: 'POST', body: JSON.stringify(record) });
}
The horizontal pipeline below shows the ordering that makes this correct: capture, then a synchronous snapshot, then the async send.
The single idea that ties all five steps together is detach before you yield. The moment your code hits an await, a .then, a setTimeout, or hands work to a transport queue, the buffer is free to keep changing underneath you. So the copy must be pulled off on the same uninterrupted run of synchronous code that observed the error. Once it is a frozen, standalone array, it does not matter how long the network call takes or how many crumbs arrive afterward — the record already froze the truth. This is why captureError is deliberately synchronous up to the point it schedules ship: the boundary between “still synchronous” and “now async” is exactly the boundary the snapshot has to sit on.
Verification
Prove that later writes to the live buffer cannot alter an already-captured snapshot. This is the assertion that would have failed under the broken reference approach.
// verify.js — run with: node --test
import test from 'node:test';
import assert from 'node:assert';
import { BreadcrumbRing } from './ring.js';
test('a snapshot is immune to writes made after capture', () => {
const trail = new BreadcrumbRing(3);
trail.add('nav', 'a');
trail.add('nav', 'b');
const captured = trail.snapshot(); // freeze the trail "at throw time"
trail.add('nav', 'c'); // events that happen AFTER the throw
trail.add('nav', 'd'); // this one overwrites the oldest slot
assert.equal(captured.length, 2); // snapshot unchanged by later writes
assert.equal(captured[0].message, 'a'); // oldest crumb preserved
assert.equal(captured[1].message, 'b'); // newest-at-capture preserved
assert.equal(trail.snapshot().length, 3); // live ring stayed bounded at 3
});
A green run here means the record you ship is a faithful photograph of the seconds before the crash, not a live feed that keeps changing while the request is in flight. To make the guarantee airtight, add a second assertion that mutates a nested data object after capture and confirms the snapshot’s copy is untouched — that is the shallow-copy trap the edge cases below call out, and it is the one bug a naive spread leaves behind. Run both assertions in CI so a future refactor that swaps the deep copy for a reference fails the build the same day it lands, rather than surfacing weeks later as a handful of confusing, half-empty error records that no one can reproduce locally. The before/after below contrasts the two attachment strategies.
Edge Cases & Gotchas
- Shallow copies still share nested objects. Spreading a crumb with
{ ...c }copies the top level but leavesdatashared. If an instrument later mutates that samedataobject, your snapshot changes too. Copy the nested field explicitly (data: { ...c.data }) or, for deeper structures, serialize it at record time so nothing is shared by reference. - One global ring bleeds across server requests. On the browser a module-level ring is fine, but on Node.js a single shared ring mixes crumbs from concurrent requests. Store a per-request ring in
AsyncLocalStorageand snapshot that store, so one user’s navigation never shows up in another user’s error. - Recording inside the transport re-introduces the bug. If your
fetchwrapper adds anhttpcrumb and your capture path also runs through that wrapper, the act of shipping the error appends a crumb to the trail. Snapshot before you ship, and the transport’s own crumbs land after the frozen copy, where they belong. - A crash mid-snapshot must not swallow the error. Wrap
snapshot()in a guard that returns[]on failure. A logging layer that throws while copying breadcrumbs converts a caught error into an uncaught one, which is strictly worse than shipping an empty trail.
FAQ
Why not just deep-clone the whole record with structuredClone?
You can, and for small trails it is a clean one-liner. The reason the examples copy field by field is control and cost: structuredClone will throw on functions and some host objects that occasionally sneak into data, and it copies the entire record including the Error, which you usually want to serialize separately. Copying only the breadcrumb array — the one thing that is racing against the live buffer — is cheaper and cannot be tripped up by an odd value in context. If your crumbs are strictly plain data, structuredClone(trail.snapshot()) is a perfectly good belt-and-suspenders addition.
Does the snapshot need to happen before I build the rest of the error record?
It needs to happen before the first await or callback boundary after the catch — not before any particular field assignment. Everything you do synchronously in the catch block sees the same buffer state, so building the schema envelope, reading the correlation ID, and snapshotting the ring can happen in any order as long as they all finish before control yields to the event loop. The instant you await, the guarantee is gone. Structuring captureError to be synchronous up to the point it schedules the async send, as in step 4, keeps you safely inside that window.
My trail is always empty even without any await — what else causes that?
The usual culprits are a buffer that is reset on navigation and an instrument that never actually fires. If you clear the ring on route change to avoid cross-page bleed, an error immediately after navigation has nothing to show; keep a few crumbs across the boundary instead of wiping it. And if you wrapped fetch or added listeners after the code that throws already ran, no crumbs were ever recorded — install instruments at startup, before the first user interaction, so the trail is warm by the time anything fails. The global capture hooks in Mastering window.onerror and Global Event Listeners are a good place to confirm your handlers are attached early.
Related
- Structured Error Logging & Breadcrumbs — the parent guide that wraps this trail in a full versioned, redacted log envelope
- Async Stack Traces & Error Cause Chains — preserving causal ordering across the same async boundaries that corrupt live buffers
- Mastering window.onerror and Global Event Listeners — installing capture hooks early so the trail is warm before the first throw
- Core JavaScript Error Handling & Boundaries — the reference section covering every capture surface these breadcrumbs attach to