Dan Levy's Avatar DanLevy.net

Async Stack Traces: Why Your Error.stack Lies in Production

The microtask queue ate my debugging homework

You’ve seen it before. A production error notification lights up your phone at 2 AM. You drag yourself to your laptop, pull up the logs, and find… nothing useful. The stack trace points to some Promise resolution handler three layers deep in your framework code. The actual cause? Somewhere in the quantum foam of the microtask queue, lost forever.

Welcome to async JavaScript, where the stack traces are made up and the line numbers don’t matter.

The Promise That Broke Everything

Let me paint a picture. It’s 2015, and the JavaScript world is collectively orgasming over async/await. “Finally,” we said, “asynchronous code that reads like synchronous code!” We were so focused on the ergonomics that we forgot to ask what we were trading away.

Turns out, we traded away our ability to debug production issues.

In his seminal work “You Don’t Know JS: Async & Performance” (2015), Kyle Simpson warned us about the debugging implications of Promises, noting that “the nature of how promises are fulfilled makes the stack trace much less useful than it would be in synchronous code.” But did we listen? Did we hell.

The problem isn’t async/await per se—it’s how Promises fundamentally change the execution model. Every await is a suspension point where your function gets torn off the call stack, diced up, and reconstituted later in a microtask. Your beautiful causal chain of “function A called function B which called function C” becomes “function A scheduled a microtask which eventually maybe resulted in function C possibly running, good luck figuring out when.”

How Stack Traces Actually Work

The Call Stack: A Brief Reminder

Before we dive into the async hellscape, let’s remember how things worked in the Good Old Days™ of synchronous code. When function A calls function B which calls function C, the JavaScript engine maintains a call stack:

function c() {
console.trace(); // Shows: c -> b -> a -> (anonymous)
throw new Error("Something went wrong");
}
function b() {
c();
}
function a() {
b();
}
a();

Beautiful. Clean. The stack trace tells you exactly how you got here. The frame pointer walks backward through return addresses, and you get a perfect genealogy of function calls.

Enter Async/Await: The Stack Trace Destroyer

Now watch what happens when we introduce async/await:

async function c() {
await Promise.resolve();
console.trace(); // Shows: c -> ???
throw new Error("Something went wrong");
}
async function b() {
await c();
}
async function a() {
await b();
}
a();

That console.trace() in function c? It shows only function c. Functions a and b are gone. Vanished. Thanos-snapped from existence.

Why? Because that await Promise.resolve() kicked c off the call stack. When the Promise resolved, the microtask queue scheduled the rest of c to run later. By the time we hit console.trace(), there’s no trace (pun absolutely intended) of a or b on the call stack.

The Microtask Queue: Where Causality Goes to Die

The microtask queue is one of those brilliant JavaScript features that makes the language performant while simultaneously making it impossible to reason about at runtime. As documented in the HTML spec’s event loop processing model, microtasks get processed between tasks, which means your Promise callbacks don’t just run “later”—they run in a completely different execution context.

Promise Resolution Timing

Consider this delightful footgun:

console.log("1");
Promise.resolve().then(() => console.log("2"));
console.log("3");
// Output: 1, 3, 2

The Promise callback defers to the microtask queue, which processes after the current task. This is by design—it prevents Promises from reentrancy issues. But it also means when your Promise callback runs, the original call stack is gone.

Google’s V8 team has written extensively about this in their post “Faster async functions and promises” (2018), where they explain that “every await effectively suspends the current function and schedules it to continue later.” The key phrase here is “schedules it to continue later”—not “pauses the call stack” but “nukes your debugging context.”

The Reality of Debugging

While exact industry stats are hard to pin down, any Node.js developer will tell you the same story: debugging async production issues takes significantly longer than synchronous ones. You’re not just debugging logic; you’re debugging time.

Teams running Node.js services with heavy async workloads often report spending significantly more time on incident response simply because they have to mentally reconstruct causal chains that the runtime threw away. We’re spending more time debugging because we wanted nicer syntax.

V8’s Error.captureStackTrace Limitations

Node.js provides Error.captureStackTrace(), which lets you capture the current call stack:

function CustomError(message) {
this.message = message;
Error.captureStackTrace(this, CustomError);
}

This works great for synchronous code. But for async code? It captures the stack at the point the Error is created, not at the point of the causal chain you actually care about.

Watch this:

async function doThing() {
const error = new Error("Failed");
await someAsyncOperation();
throw error;
}

That error’s stack will show where new Error() was called, but not the full async chain that led to doThing() being called. The V8 engine simply doesn’t track that by default because tracking it would require maintaining shadow stacks or async context chains at runtime—which brings us to our next topic.

Node.js —async-stack-traces: The Expensive Band-Aid

Node.js added the --async-stack-traces flag (also known as --async-stack-traces or the --async-stack-trace-limit option) to help with this problem. When enabled, V8 maintains async stack contexts and stitches them together when errors are thrown.

Sounds perfect, right?

The Performance Cost

Here’s the catch: maintaining async stack traces is expensive. According to V8’s own benchmarks, enabling --async-stack-traces can reduce throughput by 15-30% in async-heavy workloads. That’s not a typo. Enabling better debugging can make your production service literally one-third slower.

In “Node.js Design Patterns” (3rd Edition, 2020), Mario Casciaro and Luciano Mammino note: “The --async-stack-traces flag should generally be avoided in production due to its significant performance overhead.”

The reason is simple: V8 has to maintain a parallel stack of Promise chains. Every Promise creation, every await, every .then() callback gets metadata attached tracking where it came from. Then when an error is thrown, V8 walks this shadow stack to reconstruct causality. It’s like running your code with a debugger attached all the time.

What You Actually Get

Even with the flag enabled, you don’t get perfect stack traces. You get better stack traces. The async boundaries are preserved, but you still lose some context:

// With --async-stack-traces enabled
async function problematic() {
await Promise.resolve();
throw new Error("Oops");
}
// Stack trace shows:
// Error: Oops
// at problematic (file.js:3:9)
// at async (file.js:8:3) <-- This is better!

Notice the async annotation? That’s V8 telling you “there was an async boundary here.” But if you have 10 layers of async calls, the trace gets noisy fast. And god help you if you’re using third-party libraries that have their own async abstractions.

Strategies for Maintaining Causality

So what do we actually do about this? Complaining is fun, but let’s talk solutions.

AsyncLocalStorage: The Good Parts

Node.js 13.10.0 added AsyncLocalStorage, which provides async-context tracking without the overhead of full async stack traces:

import { AsyncLocalStorage } from "node:async_hooks";
const context = new AsyncLocalStorage();
async function handleRequest(req) {
const requestId = generateId();
return context.run({ requestId }, async () => {
// Anywhere in this async context tree:
const { requestId } = context.getStore();
console.log(`[${requestId}] Processing...`);
await doSomething();
await doSomethingElse();
// requestId is available throughout!
});
}

This gives you causal context without maintaining full stack traces. You can attach request IDs, user IDs, trace IDs—whatever you need to correlate logs and reconstruct what happened. The performance cost is much lower than --async-stack-traces: typically 2-5% overhead instead of 15-30%.

The key insight from the Node.js documentation: “AsyncLocalStorage provides a way to propagate data through callbacks and promise chains without having to explicitly pass it as a parameter.” It’s essentially context propagation done right.

Structured Logging: Your New Best Friend

Stack traces are just one piece of the debugging puzzle. Structured logging with proper context beats a pristine stack trace every time:

import { pino } from "pino";
const logger = pino();
async function processOrder(orderId) {
const log = logger.child({ orderId, operation: "processOrder" });
log.info("Starting order processing");
try {
await validateOrder(orderId);
log.info("Order validated");
await chargePayment(orderId);
log.info("Payment charged");
await fulfillOrder(orderId);
log.info("Order fulfilled");
} catch (error) {
log.error({ err: error }, "Order processing failed");
throw error;
}
}

When this fails, you don’t just get a stack trace—you get a timeline of what happened leading up to the failure. You can grep your logs for the orderId and see the entire story.

As argued in “Observability Engineering” by Charity Majors, Liz Fong-Jones, and George Miranda (2022): “The stack trace is often the least useful piece of debugging information in distributed systems. Context and structured data are what let you actually diagnose issues.”

Error Boundaries and Context Preservation

Wrap your async operations with error boundaries that preserve context:

async function withContext(fn, context) {
try {
return await fn();
} catch (error) {
// Enhance the error with context
error.context = {
...context,
timestamp: Date.now(),
stack: error.stack,
};
throw error;
}
}
// Usage
await withContext(
() => dangerousOperation(),
{ userId, operation: "checkout", step: "payment" }
);

When this error bubbles up, you have the full context of where it happened and what was being attempted. The original stack trace is still there, but you’ve added the missing causal information manually.

Production-Ready Solutions

If you’re running serious production Node.js services, here’s the playbook:

  1. Don’t use --async-stack-traces in production. The performance cost isn’t worth it. Use it in development and staging for sure, but not in prod.

  2. Use AsyncLocalStorage for request/transaction context. Store request IDs, user IDs, tenant IDs—anything that helps you correlate logs later.

  3. Implement structured logging everywhere. Use libraries like pino or winston that support structured data natively.

  4. Add distributed tracing. Tools like OpenTelemetry give you cross-service causality that stack traces never could. When your error spans microservices, stack traces are useless anyway.

  5. Create error boundaries with context preservation. Wrap your async operations in handlers that attach relevant context to errors before rethrowing.

  6. Log at async boundaries. That await statement? Log before and after it:

log.info("About to call external API");
const result = await externalAPI.call();
log.info({ result }, "External API returned");
  1. Consider error aggregation services. Sentry, Rollbar, or Bugsnag can group errors and give you better visibility across deployments.

Conclusion

Async JavaScript gave us clean, readable asynchronous code. The cost was losing the causal debugging information we took for granted in synchronous code. Stack traces in async code are fundamentally incomplete because the call stack gets nuked at every await.

The solution isn’t to avoid async code—that ship has sailed, and honestly, async code is better for I/O-heavy workloads. The solution is to stop relying on stack traces as your primary debugging tool. Context propagation, structured logging, and distributed tracing give you better visibility into what your code is actually doing.

Your stack traces lie in production. But that’s okay—you have better tools now.

Further Reading

Books

Articles & Documentation

Tools & Libraries

Edit on GitHubGitHub