Async Stack Traces: Why `Error.stack` Lies to You
The microtask queue ate my homework (and my debugging context).
It is 2 AM. The PagerDuty alarm is blaring.
You open the logs and see this:
Error: Cannot read properties of undefined (reading 'id') at processTicksAndRejections (node:internal/process/task_queues:96:5)That’s it. No function name. No line number. No file path. Just “processTicksAndRejections”.
Welcome to async JavaScript, where the stack traces are made up and the line numbers don’t matter.
Why Stack Traces Break
In synchronous code, the Call Stack is a beautiful genealogy. A called B, B called C. When C crashes, you can see exactly how you got there.
In async code (async/await), every await keyword is a suspension point.
When you await, your function is torn off the stack. It’s put into a cryogenic freezer called the Microtask Queue. The stack is now empty (or doing something else).
When the Promise resolves, your function is thawed out and tossed back onto the stack. But the history is gone.
The engine has no idea who called await 500 milliseconds ago. It just knows it has a task to run.
V8’s Attempts to Fix It
Node.js tries to help. We have:
Error.captureStackTrace(): Captures the stack at creation. Useless if the error is thrown later.--async-stack-traces: A flag that makes Node.js keep a “shadow stack” of promise chains.- The Cost: It makes your app 30% slower.
- The Result: It helps, but it gets noisy fast.
The Real Solution: AsyncLocalStorage
If you want to survive production, stop looking at stack traces. Look at causality.
We need to attach context (User ID, Request ID) to the “thread” of execution, even as it jumps between the Stack and the Microtask Queue.
Node.js has a builtin tool for this: AsyncLocalStorage.
import { AsyncLocalStorage } from 'async_hooks';
const context = new AsyncLocalStorage();
// 1. Wrap the requestcontext.run({ requestId: '123' }, () => { // 2. Call deep async code await processOrder();});
// 3. Deep inside processOrder:async function processOrder() { await db.query();
// Magic! We can still see the requestId const { requestId } = context.getStore(); console.log(`[${requestId}] Failed to process order`);}It doesn’t matter how many awaits happen in between. The context survives.
Production Playbook
- Stop trusting
err.stack. It’s incomplete by design. - Use structured logging. Attach
requestIdto every single log line usingAsyncLocalStorage. - Trace, don’t stack. Use OpenTelemetry. It visualizes the causal chain across services, which is what you actually care about.
Your code is async. Your debugging context shouldn’t be.



