Enabling V8 Async Stack Traces in Node.js
When an error is thrown after an await, the stack you get often stops at the first suspension point and hides every function that led there. This how-to shows exactly how to switch on V8 async stack traces, confirm they are active, and read the resulting at async frames so a rejected promise names its true caller chain. It sits under the Async Stack Traces & Error Cause Chains guide and the broader Core JavaScript Error Handling & Boundaries reference; if you also need the errors themselves linked layer by layer, pair this with Preserving Async Context with Error cause.
Symptom / Trigger
You catch an error at a top-level handler, print err.stack, and the trace names only the innermost async function plus a few engine frames. The functions that awaited their way down to the failure are absent, so the trace tells you where it broke but not how you got there.
Error: read timeout
at fetchRow (/app/db/pool.js:41:11)
at process.processTicksAndRejections (node:internal/process/task_queues:95:5)
Two await-separated callers — loadInvoice and the route that invoked it — are nowhere in the trace, even though both were on the logical call path a microtask earlier. A synchronous throw from the same file would have listed every caller; the async boundary erased them. The tell that you are looking at a truncated async trace, rather than a genuinely shallow one, is the processTicksAndRejections frame: it marks the exact point where the engine resumed a microtask, and everything above it in the logical call path has been dropped. Whenever you see application code sitting directly beneath that internal frame with no at async lines in between, the reconstruction step never ran.
The impact is not cosmetic. Two unrelated user actions that happen to fail inside the same low-level helper produce byte-identical stacks, so your error tracker groups them into one issue and buries the fact that they have different origins. The trace that would have distinguished them — the caller path — is precisely what the async boundary discarded.
Root Cause Explanation
At each await, the JavaScript engine suspends the function and unwinds the native call stack completely. The continuation after the await runs later as a fresh microtask with an almost empty stack, so by the time the throw happens the physical frames of the callers no longer exist. V8’s async stack traces feature reconstructs those suspended frames from the promise chain and stitches them back into err.stack under at async lines — but only when the feature is active and only when a real Error object captured a stack at throw time.
// The await erases the physical caller frames before the throw runs.
async function loadInvoice(id) {
const row = await fetchRow(id); // fetchRow rejects HERE, one microtask later
return format(row); // loadInvoice's frame is already gone
}
Zero-cost async stack traces have shipped on by default in V8 since Node.js 12, so most of the time the feature is present. The trap is that it can be silently switched off — by an old --no-async-stack-traces flag left in a start script, by a bundler that rewrites async/await down to generator or promise-chain shims V8 cannot recognize, or by a stack-trace-limit set so low the async lines are truncated away. When any of those apply, the trace collapses back to the innermost-frame-only form above.
It helps to be precise about what “reconstruction” means here, because it explains every failure mode later. V8 does not record the caller frames when the await suspends; that would cost memory on the hot path for traces most code never reads. Instead it keeps a lightweight link from each pending promise to the async function awaiting it, and only when something reads err.stack does it walk those links backward to rebuild the suspended callers. That design is why the feature is called zero-cost: you pay for the reconstruction lazily, once, at the moment of inspection. It is also why a broken promise link — a value that skipped a native await, or an Error whose stack was captured somewhere other than the throw — leaves a gap the walk cannot bridge.
Step-by-Step Fix
- Confirm the feature is on at boot; a healthy async trace names both an inner and an outer async function.
// boot-probe.js — log once at startup whether async frames survive an await
async function inner() { await Promise.resolve(); throw new Error('probe'); }
async function outer() { await inner(); }
outer().catch((e) => {
// Both names present => async stack traces are active in this runtime.
const ok = /at inner/.test(e.stack) && /at async outer/.test(e.stack);
console.log('[boot] async stack traces active:', ok);
});
- Pass the V8 flag explicitly at the entry point so an inherited
--no-async-stack-tracescannot win.
# --async-stack-traces is default-on, but stating it defeats a stale opt-out.
node --async-stack-traces server.js
- Raise
--stack-trace-limitabove the default of 10 so deep await chains are not clipped mid-trace.
# Keep up to 50 frames so long async paths and their at async lines survive.
node --async-stack-traces --stack-trace-limit=50 server.js
- Throw real
Errorobjects only; a rejected string or number carries no captured stack for V8 to extend.
// A stack is captured only for Error instances — never reject with a bare value.
async function fetchRow(id) {
if (!id) throw new Error(`missing id`); // GOOD: has .stack for async stitching
// throw `missing id`; // BAD: no stack, chain dead-ends here
return query(id);
}
- Read the stitched trace at a single top handler, splitting on
at asyncto see the awaited path.
// top-handler.js — print the reconstructed async call path on failure
export async function runRoute(req, res, work) {
try {
res.json(await work(req));
} catch (err) {
// Lines tagged "at async" are the reconstructed suspended callers.
const asyncFrames = err.stack.split('\n').filter((l) => l.includes('at async'));
console.error('async path:', asyncFrames.join(' <- '));
res.status(500).json({ error: 'internal_error' });
}
}
Read these five steps as one pipeline rather than five isolated tweaks: you probe the runtime, force the flag on so nothing inherited can override it, widen the frame budget, guarantee every throw carries a stack, and finally consume the recovered path at exactly one place. Skip any middle step and the last one quietly degrades — a low stack-trace-limit clips the very frames the handler is trying to print, and a non-Error throw gives the handler nothing to split on.
Verification
Prove the fix with an assertion rather than by eyeballing a log. A genuinely asynchronous throw path must name the deep async function in the top error’s stack. Eyeballing is unreliable precisely because the feature degrades silently: the same code prints a full trace in one environment and a stub in another, and nothing in the output announces which mode you are in. A test that asserts on the presence of an at async line turns that silent difference into a red build.
// async-frames.test.js — the outer stack must mention the inner async function
import { test } from 'node:test';
import assert from 'node:assert/strict';
test('async stack trace spans the await boundary', async () => {
async function deep() { await Promise.resolve(); throw new Error('deep'); }
async function mid() { await deep(); }
const err = await mid().catch((e) => e);
// With the feature active, "deep" appears even though it is past an await.
assert.match(err.stack, /deep/);
assert.match(err.stack, /at async/);
});
Run the suite with the same flags production uses, so a build that strips the feature fails here instead of surprising you live:
# Match production flags so the async assertion is meaningful.
node --async-stack-traces --stack-trace-limit=50 --test
Edge Cases & Gotchas
Most lost traces trace back to one of a few structural gaps between what your source looks like and what the engine actually runs. Watch for these in particular:
- Transpiling
async/awaitto ES2017-or-lower promise chains hides the frames: V8 only stitches nativeawait. Set your build target toes2018+ and keep async functions un-downleveled on the server. Promise.allandsetTimeoutcallbacks break the async link because the awaited value did not flow through a single continuation; wrap each branch so a realawaitboundary exists for V8 to follow.- A
--stack-trace-limitset low for performance clips the deepestat asynclines first, since they are appended last — raise it or accept losing the origin caller. - Errors created far from where they are thrown (a cached or pre-built
Error) captured their stack at construction, not at throw, so the async path reflects the wrong moment; construct theErrorat the throw site. - Worker threads and child processes each run their own V8 isolate, so an async trace built in a worker does not extend into the parent that spawned it; treat the boundary as a hard cut and re-anchor a fresh trace on the receiving side.
FAQ
Are V8 async stack traces on by default, or do I have to enable them?
They are on by default in every Node.js release since 12, so in a clean environment you do not need to do anything. The reason this reads like an “enable” task is that the feature is easy to lose without noticing: a stale --no-async-stack-traces in a start script, an aggressive transpile target that rewrites await, or a serverless base image with the flag off will all disable it. Passing --async-stack-traces explicitly and probing at boot turns an invisible default into a guarantee.
Do async stack traces add runtime overhead?
V8 implements them as “zero-cost” async stack traces, meaning the frames are reconstructed lazily from the promise chain only when err.stack is actually read, not eagerly on every await. For normal request paths where most promises resolve, the cost is negligible. The one place to watch is raising --stack-trace-limit very high in a hot loop that formats thousands of stacks per second, since each formatting pass then walks more frames.
Why does my stack still stop at the first await after enabling the flag?
The most common cause is that the thrown value is not an Error instance, so there was never a captured stack for V8 to extend — always throw new Error(...) rather than rejecting with a string. The second most common cause is a build step that downlevels async/await; check the compiled output for regeneratorRuntime or __awaiter shims, which V8 cannot recognize as native await boundaries. To keep the recovered frames wired into a durable, serializable chain across layers, combine this with Flattening Nested Error cause Chains for Logging.
Related
- Async Stack Traces & Error Cause Chains — parent guide combining engine traces with durable cause chains
- Core JavaScript Error Handling & Boundaries — the reference covering every interception and boundary pattern
- Preserving Async Context with Error cause — attaching the original error when you re-throw across async layers
- Flattening Nested Error cause Chains for Logging — serializing every level of a chain into one structured log entry