Async Stack Traces & Error Cause Chains
An error that surfaces three await boundaries away from where it originated usually arrives stripped of the one thing that would make it debuggable: the chain of calls that led to it. By the time a rejected fetch bubbles up through a repository function, a service layer, and a request handler, the default stack trace often points only at the outermost await and leaves you guessing about the origin throw. This guide shows how to keep a readable causal chain intact from the deepest failure to the top-level handler, combining the standard Error.cause option with V8’s async stack trace instrumentation. It belongs to the Core JavaScript Error Handling & Boundaries reference and assumes you have already worked through Handling Unhandled Promise Rejections in Modern JS and understand the process-level distinctions covered in Node.js uncaughtException vs unhandledRejection. You will need a runtime with native Error.cause support (Node.js 16.9+ or any 2022-era browser) and comfort reading raw err.stack output.
By the end of this guide you will be able to:
- Wrap and re-throw errors at every layer boundary so the original throw is never lost.
- Read and print a full cause chain from a single top-level catch block.
- Enable V8 async stack traces and understand exactly which async frames they preserve.
- Serialize a multi-level cause chain into a flat structure your telemetry backend can group.
- Diagnose the common failure modes that silently truncate a chain in production.
Problem Framing & Symptom Identification
Synchronous JavaScript gives you a contiguous stack: every frame from the throw site up to the caller sits in one err.stack string. Asynchronous code breaks that contiguity. When execution suspends at an await, the physical call stack unwinds completely; the continuation runs later as a fresh microtask with its own, much shorter stack. Without instrumentation, an error thrown after await db.query() shows a stack that begins inside the database driver and ends at the first await in your code — the functions that called your function are simply gone.
The symptom set is distinctive:
- A production error reads
Error: connect ECONNREFUSEDwith a stack that lists onlynode:internal/netframes and none of your application code. - Two completely different user actions produce errors that your tracker groups together, because their truncated stacks are byte-identical below the shared low-level call.
- A wrapped error says
Error: Failed to load dashboardbut gives you no way to see that the underlying cause was a JSON parse failure in one specific upstream service. - Local debugging with
--async-stack-tracesshows a clean multi-frame trace, but the same code in a serverless production build shows a two-line stub.
There are two independent mechanisms at work, and you need both. Error.cause is a data mechanism: it links error objects together so each layer can attach its own message while retaining a pointer to what it caught. Async stack traces are an engine mechanism: V8 stitches the suspended frames back together so a single err.stack spans await boundaries. Error.cause survives serialization and works across any transport; async stack traces are richer but live only in memory and evaporate the moment you cross a process boundary. A robust setup uses the cause chain as the durable backbone and async stack traces as the in-process detail layer.
Prerequisites & Environment Setup
The examples target Node.js 18 or later, where Error.cause and async stack traces are both on by default, plus modern browsers (Chrome 93+, Firefox 91+, Safari 15+) for the client-side snippets. TypeScript users need 4.6 or newer for ErrorOptions typings. No third-party runtime dependency is required for the core pattern; the telemetry section references a generic telemetry.capture(error, meta) sink you substitute with your own SDK.
# Confirm Error.cause and async stack traces are available
node --version # expect v18.x or later
# Lock the runtime for the project
echo "18" > .nvmrc
nvm use
# TypeScript needs 4.6+ for the ErrorOptions type
npm install --save-dev typescript@">=4.6"
Async stack traces are enabled by default in V8 since Node.js 12, but two runtime settings materially change how much of the chain you keep. Set them at the entry point before any work begins:
# Raise the captured frame count well above the default of 10,
# and translate frames through source maps at the process level.
node --stack-trace-limit=50 --enable-source-maps server.js
If your deployment bundles or minifies server code, verify the async flag survives your build. Some serverless wrappers and older transpile targets disable zero-cost async stack traces, which is the single most common reason a chain that is complete locally is truncated in production. Confirm at boot:
// boot-check.js — run once at startup and log the result
async function inner() { throw new Error('probe'); }
async function outer() { await inner(); }
outer().catch((e) => {
// A healthy async trace mentions BOTH inner and outer.
const hasAsyncFrames = /at async outer/.test(e.stack) && /at inner/.test(e.stack);
console.log('[boot] async stack traces active:', hasAsyncFrames);
});
Step-by-Step Implementation
Step 1 — Wrap and re-throw with the cause option at each boundary
The foundation is a strict rule: whenever a layer catches an error it did not create, it re-throws a new error whose cause is the caught value. The second argument to the Error constructor is an options object with a cause field. This is standard ECMAScript, not a library feature.
// repository.js — the layer closest to the failing I/O
export async function loadUserRecord(userId) {
try {
const res = await fetch(`https://api.internal/users/${userId}`);
if (!res.ok) {
// Encode the HTTP status into the message; still throw so callers see it.
throw new Error(`Upstream responded ${res.status} for user ${userId}`);
}
return await res.json();
} catch (origin) {
// Wrap: new message for THIS layer, cause points at what we caught.
throw new Error(`repository.loadUserRecord failed for ${userId}`, { cause: origin });
}
}
The wrapping message describes the failure in this layer’s vocabulary. The cause field preserves the exact object the layer caught — whether that is the res.status error thrown two lines up or a raw TypeError from fetch when the network is down. Nothing is discarded.
Step 2 — Continue the chain through every intermediate layer
Each layer above the repository repeats the pattern. The chain grows one link per boundary, so a request that crosses four layers yields a four-deep cause chain terminating at the origin throw.
// service.js — business logic layer above the repository
import { loadUserRecord } from './repository.js';
export async function buildDashboard(userId) {
try {
const record = await loadUserRecord(userId);
return assembleWidgets(record); // may throw synchronously
} catch (repoErr) {
// This layer's message is user-flow oriented, not I/O oriented.
throw new Error(`Unable to build dashboard for ${userId}`, { cause: repoErr });
}
}
Resist the urge to log at every layer. Logging mid-chain produces duplicate, partial reports of the same incident. Wrap and re-throw silently; log exactly once, at the top.
Step 3 — Read the entire chain at the top-level handler
At the top of the stack — an Express handler, a queue consumer, a process.on('unhandledRejection') hook — walk the cause links to reconstruct the full story. A small helper flattens the chain into an array from outermost wrapper to origin.
// chain.js — walk the cause pointers, guarding against cycles
export function flattenCauseChain(err, maxDepth = 10) {
const chain = [];
const seen = new Set();
let current = err;
while (current instanceof Error && !seen.has(current) && chain.length < maxDepth) {
seen.add(current); // cycle guard: an error whose cause loops back to itself
chain.push(current);
current = current.cause; // undefined ends the loop naturally
}
return chain;
}
// handler.js — the single place that logs
import { buildDashboard } from './service.js';
import { flattenCauseChain } from './chain.js';
export async function dashboardRoute(req, res) {
try {
res.json(await buildDashboard(req.params.userId));
} catch (topErr) {
const chain = flattenCauseChain(topErr);
// Print outermost -> origin so the log reads top-down like the request flow.
console.error(chain.map((e, i) => `${' '.repeat(i)}${e.message}`).join('\n'));
res.status(500).json({ error: 'internal_error' });
}
}
Step 4 — Print a native cause chain with the built-in formatter
Node.js and modern browsers already understand cause. When you pass a chained error to console.error (or read util.inspect), the runtime appends a [cause]: section recursively. Rely on this for local debugging; it renders each layer’s message and stack in order.
// Node.js prints the cause chain automatically with util.inspect
import { inspect } from 'node:util';
buildDashboard('u_42').catch((err) => {
// depth: Infinity ensures deeply nested causes are not collapsed to [Object]
console.error(inspect(err, { depth: Infinity }));
// Output includes: ... [cause]: Error: repository.loadUserRecord failed ...
// ... [cause]: Error: Upstream responded 503 ...
});
Step 5 — Preserve async frames with a typed wrapper class
Plain Error wrapping works, but a small typed subclass makes the chain self-describing and gives telemetry a stable class name to group on. Setting this.name and capturing the layer keeps async frames attached to a meaningful identity. (For the full subclass discipline, see TypeScript Typed Errors and Custom Error Classes.)
// LayeredError.ts — a cause-aware Error subclass with a layer tag
export class LayeredError extends Error {
readonly layer: string;
constructor(message: string, layer: string, cause?: unknown) {
// Forward cause through ErrorOptions so the native chain stays intact.
super(message, { cause });
this.name = 'LayeredError';
this.layer = layer;
// Repair the prototype chain for ES5 targets; harmless on modern targets.
Object.setPrototypeOf(this, new.target.prototype);
}
}
// Usage keeps the layer name close to the wrap site:
// throw new LayeredError('build dashboard failed', 'service', repoErr);
Step 6 — Guarantee a captured stack at the origin throw
Async stack traces stitch frames only if a stack was captured at throw time. Values that are not Error instances — a rejected Promise.reject('nope'), a thrown string — carry no .stack, so the chain dead-ends with no origin frames. Normalize non-Error causes the moment you catch them.
// normalize.js — ensure the deepest link always has a real stack
export function toError(value) {
if (value instanceof Error) return value;
// Wrapping in Error() captures a stack HERE, marking the normalization point.
const e = new Error(typeof value === 'string' ? value : JSON.stringify(value));
e.name = 'NonError';
return e;
}
// Apply at the boundary so every cause is a stack-bearing Error:
// catch (raw) { throw new Error('layer failed', { cause: toError(raw) }); }
Production Telemetry Integration
In-memory async stack traces do not survive the jump to your observability backend — the SDK serializes each Error to JSON, and cause is a non-enumerable relationship that most serializers drop. The durable artifact is the flattened chain. Convert it into an array of plain objects your backend can index, and derive a stable grouping key from the origin rather than the outermost wrapper, so unrelated user actions that share a wrapper message still group by their true root cause.
// telemetry-chain.js — turn a chain into a durable, groupable payload
import { flattenCauseChain } from './chain.js';
export function serializeChain(topErr) {
const chain = flattenCauseChain(topErr);
const origin = chain[chain.length - 1]; // deepest link = true root cause
return {
// Group on the origin so wrapper messages do not merge distinct incidents.
fingerprint: `${origin.name}:${firstFrame(origin.stack)}`,
message: topErr.message,
chain: chain.map((e, depth) => ({
depth,
name: e.name,
message: e.message,
layer: e.layer ?? null, // present on LayeredError instances
stack: e.stack, // raw; backend symbolicates via source maps
})),
capturedAt: Date.now(),
};
}
function firstFrame(stack = '') {
// Extract the topmost "at ..." line as a compact origin signature.
const match = stack.split('\n').find((l) => l.trim().startsWith('at '));
return match ? match.trim() : 'no-frame';
}
Forward the serialized structure through your SDK. Most accept a free-form extra/context object alongside the top-level Error:
// Pass the top error for native grouping AND the flat chain as context.
telemetry.capture(topErr, {
errorChain: serializeChain(topErr),
asyncStackActive: /at async /.test(topErr.stack ?? ''),
});
Because Error.cause chains and async stack traces both feed the same downstream pipeline, most teams standardize this serialization inside the SDK wrapper they already use for integrating observability SDKs such as Sentry, Datadog, and OpenTelemetry. Keep the stack field raw and let the backend translate frames server-side; symbolicating client-side before upload discards the offsets some backends need for accurate grouping.
Verification & Testing
Verification has two independent claims to prove: that the cause chain reaches the true origin, and that async frames are actually present in the top error’s stack. Test both directly rather than eyeballing console output.
// chain.test.js — assert the chain terminates at the origin
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { flattenCauseChain } from './chain.js';
test('cause chain reaches the origin throw', () => {
const origin = new Error('ECONNREFUSED');
const repo = new Error('repository failed', { cause: origin });
const service = new Error('dashboard failed', { cause: repo });
const chain = flattenCauseChain(service);
assert.equal(chain.length, 3); // three links, no truncation
assert.equal(chain[chain.length - 1], origin); // deepest link is the origin
});
test('cycle guard prevents infinite walk', () => {
const a = new Error('a');
const b = new Error('b', { cause: a });
a.cause = b; // deliberate cycle
assert.ok(flattenCauseChain(a).length <= 10); // bounded, does not hang
});
To prove async frames survive across await, assert on the presence of at async markers in a genuinely asynchronous throw path:
// async-stack.test.js — the top error must mention the deep async function
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('async stack trace spans the await boundary', async () => {
async function deepAsync() { await Promise.resolve(); throw new Error('deep'); }
async function midAsync() { await deepAsync(); }
const err = await midAsync().catch((e) => e);
// With async stack traces on, the stack names deepAsync, not just midAsync.
assert.match(err.stack, /deepAsync/);
});
Run both suites with the same flags production uses, so a build that disables async stack traces fails the second test rather than surprising you live:
# Match production flags so the async assertion is meaningful
node --stack-trace-limit=50 --enable-source-maps --test
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
| Top error message is descriptive but origin frames are missing | A layer logged err.message and swallowed the error instead of re-throwing with cause |
Always re-throw new Error(msg, { cause }); log only at the top handler |
| Chain dead-ends with no stack at the deepest link | Origin was a rejected non-Error value (Promise.reject('x')) with no .stack |
Normalize with toError() before wrapping so every cause is a real Error |
| Local trace spans await frames but production shows a stub | Build target or serverless wrapper disabled zero-cost async stack traces | Verify at boot with the async probe; pass --stack-trace-limit and keep the async flag |
| Telemetry groups unrelated incidents together | Fingerprint derived from the outermost wrapper message, which is identical across flows | Derive the grouping fingerprint from the origin link’s name and first frame |
flattenCauseChain hangs or repeats |
An error’s cause was assigned back into an ancestor, forming a cycle |
Track visited errors in a Set and cap depth (already in the helper) |
cause is undefined after crossing a worker or network boundary |
Structured clone and JSON serialization drop the non-enumerable cause link | Serialize the flattened chain into an array before transport; rebuild from that array |
FAQ
Do async stack traces and Error.cause do the same thing?
No — they are complementary. Async stack traces are a V8 feature that reconstructs the suspended call frames across await boundaries into one err.stack string; they are rich but exist only in the process that created the error and vanish when you serialize it. Error.cause is a standard data link between error objects that you attach manually when wrapping; it is coarser (one entry per layer, not per frame) but survives JSON serialization and transport. Use async stack traces for in-process detail and the cause chain as the durable, cross-boundary backbone.
Why not just log the error at every layer where I catch it?
Because you get N partial reports of one incident. Each mid-chain log shows only the portion of the story known at that layer, and your alerting fires multiple times for a single failure. The disciplined pattern is to wrap-and-re-throw silently at every boundary and log exactly once at the top-level handler, where the complete chain is available. This keeps one incident equal to one log line and one telemetry event.
Does adding a cause increase memory usage or leak references?
Each wrap holds a reference to the error it caught, so a deep chain keeps every intermediate Error object alive until the whole chain is garbage-collected. In practice these objects are small and short-lived — they are collected as soon as the top handler finishes. The only real risk is retaining a chained error in a long-lived cache or module-level array, which would pin the entire chain; avoid storing live error objects beyond the request that produced them.
How do I keep the chain intact across a network or worker boundary?
The in-memory cause link and the async stack frames do not cross process boundaries — structured clone and JSON.stringify both drop them. Serialize the flattened chain into a plain array of { name, message, stack } objects before you send it, then either reconstruct Error instances on the far side or index the array directly in your telemetry backend. Treat the serialized array as the source of truth once you leave the originating process.
Related
- Core JavaScript Error Handling & Boundaries — parent reference covering every error interception and boundary pattern
- Handling Unhandled Promise Rejections in Modern JS — capturing async failures that escape a cause chain entirely
- Node.js uncaughtException vs unhandledRejection — where top-level handlers read the chain in a Node.js process
- TypeScript Typed Errors and Custom Error Classes — building the cause-aware subclasses used in Step 5
- Integrating Observability SDKs: Sentry, Datadog, OpenTelemetry — forwarding the serialized chain to a production backend