Preserving Async Context with Error cause
When a payment-capture worker fails three await boundaries deep, the error your on-call engineer reads at 2am usually says Error: capture job failed and nothing more — the gateway timeout that actually broke the job is gone, swallowed by a re-throw that kept the message but dropped the original object. This how-to shows the narrow discipline that fixes it: pass the caught error as the cause option every time you re-throw across an async layer, so the origin travels intact from the deepest rejection up to the handler that logs it. It is a focused companion to Async Stack Traces & Error Cause Chains within the Core JavaScript Error Handling & Boundaries reference, and it pairs naturally with Handling Unhandled Promise Rejections in Modern JS for the rejections that never reach a catch at all, and with TypeScript Typed Errors and Custom Error Classes when you want the wrapper to carry a stable type.
Symptom / Trigger
The tell is an error whose message describes the outermost failure while the stack points only at low-level runtime frames. A queue consumer captures a payment, the capture call rejects inside the HTTP client, and by the time the job runner reports it, every frame that would explain why has unwound.
Error: capture job failed for order ord_8814
at processCaptureJob (/app/jobs/capture.js:22:11)
at async Worker.run (/app/queue/worker.js:57:5)
at async Queue.poll (/app/queue/worker.js:31:7)
Nothing in that trace mentions the gateway, the timeout, or the HTTP status. The message names the job, but the origin — a FetchError: network timeout after 8000ms thrown deep in the gateway adapter — has been discarded. Two different failures (a timeout and a declined card) produce byte-identical traces here, so your tracker groups them as one incident and your dashboards lie about what is actually breaking.
You can recognize the pattern from a few recurring signs. The message always describes the highest layer that caught the error, never the layer that threw it. Retries in the queue mask the frequency, because each retry logs the same context-free line and none of them tells you whether the gateway is slow, the card processor is declining, or a downstream schema changed. And when you try to reproduce the failure locally, the code path works, because the origin condition — an eight-second timeout under production load — never fires on your laptop. The missing piece is not the reproduction; it is the record of what the deepest call was doing at the moment it gave up.
Root Cause Explanation
An await suspends the function and lets the physical call stack unwind completely; the continuation resumes later as a fresh microtask with a short stack of its own. So when a layer catches an error and throws a brand-new one, the only link back to what actually failed is whatever that layer chooses to carry forward. A bare throw new Error(msg) carries forward nothing but a string.
// gateway.js — the re-throw that destroys the trail
export async function captureCharge(orderId) {
try {
return await httpClient.post(`/charges/${orderId}/capture`);
} catch (err) {
// BUG: the original FetchError (timeout, status, url) is thrown away here.
throw new Error(`gateway capture failed for ${orderId}`);
}
}
The caught err held the timeout, the target URL, and a real stack captured at the socket. The new Error holds a message and a stack that begins at this catch block. Every layer above now sees a clean-looking error with no way to reach the origin. The stack unwinds at each await, and because no data link survives the unwind, the context is gone for good.
It is worth being precise about why the language cannot rescue you here automatically. In synchronous code, a single throw carries a contiguous stack from the throw site all the way up to the caller, so even a re-thrown error still sits inside the same physical frame chain. Across an await, that continuity is gone before your catch block even runs: the engine has already scheduled the continuation as a separate microtask. The re-thrown error is therefore a genuinely new object created in a new frame, and unless you connect it to the old one, there is no other thread of continuity to fall back on. The cause option exists precisely to be that connection — an explicit, standardized reference from the wrapper to the value it caught, defined in ECMAScript rather than provided by any library.
Step-by-Step Fix
- At the deepest layer, re-throw with the caught error passed as the
causeoption.
// gateway.js — attach the origin instead of discarding it
export async function captureCharge(orderId) {
try {
return await httpClient.post(`/charges/${orderId}/capture`);
} catch (err) {
// Second arg is ErrorOptions; cause pins the original FetchError to this throw.
throw new Error(`gateway capture failed for ${orderId}`, { cause: err });
}
}
- Repeat the same wrap at every intermediate async layer so the chain gains one link per boundary.
// payment-service.js — one layer up, same discipline
import { captureCharge } from './gateway.js';
export async function capturePayment(orderId) {
try {
return await captureCharge(orderId);
} catch (gatewayErr) {
// This layer speaks in domain terms; cause still carries the gateway error.
throw new Error(`payment capture failed for order ${orderId}`, { cause: gatewayErr });
}
}
- Log exactly once, at the top handler, by walking the
causelinks down to the origin.
// capture.js — the single place that logs, at the job boundary
export async function processCaptureJob(order) {
try {
await capturePayment(order.id);
} catch (topErr) {
// Walk .cause to print outermost -> origin instead of just the top message.
for (let e = topErr, depth = 0; e instanceof Error; e = e.cause, depth++) {
console.error(`${' '.repeat(depth)}${e.name}: ${e.message}`);
}
throw topErr; // let the queue mark the job failed, chain intact
}
}
- Normalize any non-Error rejection before wrapping, so the deepest link always carries a real stack.
// to-error.js — guarantee the origin is a stack-bearing Error
export function toError(value) {
if (value instanceof Error) return value;
// Constructing here captures a stack; without this, Promise.reject('x') dead-ends.
return new Error(typeof value === 'string' ? value : JSON.stringify(value));
}
// Use at any boundary that might catch a thrown string or rejected literal:
// catch (raw) { throw new Error('layer failed', { cause: toError(raw) }); }
The rule that makes this work is uniform: never throw a bare new Error from a catch block whose value you did not create. If you caught it, you attach it. That single habit is what keeps the origin reachable no matter how many await boundaries sit between the failure and the log line.
Verification
Assert the shape of the chain directly rather than trusting console output. A passing test proves the origin is still reachable from the top error after crossing the layers.
// capture.test.js — the origin must survive every wrap
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('cause chain keeps the origin reachable', () => {
const origin = new Error('network timeout after 8000ms');
const gateway = new Error('gateway capture failed', { cause: origin });
const service = new Error('payment capture failed', { cause: gateway });
// Walk to the deepest cause and confirm it is the original object.
let deepest = service;
while (deepest.cause instanceof Error) deepest = deepest.cause;
assert.equal(deepest, origin); // same reference, not a copy
assert.match(deepest.message, /network timeout/); // origin detail preserved
});
Run it, and the deepest link is the exact object thrown at the socket — message, name, and captured stack all intact. Node.js and modern browsers also render the chain automatically when you pass a wrapped error to console.error, appending a [cause]: section for each layer, which is the fastest way to eyeball the result during local debugging.
A second check is worth adding to your suite: prove that the discipline actually held across the real async path, not just in a hand-built chain. Invoke the top-level function against a stub that rejects the way the gateway would, catch the result, and assert that the origin message is reachable from it. That test fails loudly the day someone adds a new layer and forgets the { cause } argument, which is the exact regression this pattern is meant to prevent. Wiring the check into continuous integration turns a silent, intermittent production mystery into a deterministic red build you can fix before it ships.
Edge Cases & Gotchas
- A thrown string or
Promise.reject('nope')has no.stack, so a chain that ends on it dead-ends with no origin frames — run every raw catch throughtoError()before wrapping. causeis a non-enumerable link thatJSON.stringifyand structured clone both drop; if the chain must cross a worker or network boundary, flatten it to a plain array of{ name, message, stack }first.- Logging at every layer instead of only at the top produces N partial reports of one incident and fires your alerting repeatedly — wrap silently, log once.
- Deriving a telemetry grouping key from the outermost wrapper message merges unrelated failures, because that message is identical across flows; fingerprint on the origin link instead. See Integrating Observability SDKs: Sentry, Datadog, OpenTelemetry for wiring that into an SDK.
FAQ
Does passing cause replace the need for async stack traces?
No — they solve different halves of the problem. The cause option is a data link you attach by hand; it survives serialization, works across any transport, and gives you one entry per layer. Async stack traces are a V8 feature that stitches the suspended frames of a single throw back together, giving finer per-frame detail that lives only in the process that created the error. Use cause as the durable backbone that reaches the origin, and rely on async stack traces for the in-process detail on any one link.
Will a deep cause chain leak memory?
Each wrap holds a reference to the error it caught, so a chain keeps every intermediate Error alive until the whole chain is collected. In request- or job-scoped code these objects are small and short-lived, collected as soon as the top handler returns. The only real hazard is stashing a chained error in a long-lived cache or module-level array, which pins the entire chain; do not retain live error objects beyond the unit of work that produced them.
How do I add a typed name without breaking the native chain?
Subclass Error and forward cause through the super call: super(message, { cause }). Set this.name for a stable grouping identity and, on ES5 targets, call Object.setPrototypeOf(this, new.target.prototype) to repair instanceof. The native [cause]: formatting and any .cause walk keep working because you never touched the standard link.
Related
- Async Stack Traces & Error Cause Chains — the parent guide that combines cause chains with V8 async stack traces end to end
- Core JavaScript Error Handling & Boundaries — the reference covering every interception and boundary pattern
- Handling Unhandled Promise Rejections in Modern JS — catching async failures that never reach a wrapping catch
- TypeScript Typed Errors and Custom Error Classes — building the cause-aware subclass named in the FAQ