Debug Asynchronous Call Stacks in Node.js
Debugging asynchronous code in Node.js can be challenging because
standard error stack traces often lose their context when promises
resolve across event loop ticks. This article explains how to inspect
asynchronous call stacks in Node.js using built-in V8 features, the
Chrome DevTools debugger, and the native async_hooks module
to trace and resolve complex promise chains.
Understand the Asynchronous Stack Trace Limit
By default, older versions of Node.js only showed the stack trace up to the point where the asynchronous boundary was crossed. Modern Node.js (version 12 and later) has built-in support for zero-cost async stack traces in the V8 engine.
When an error is thrown inside an awaited promise, Node.js automatically stiches the asynchronous frames together.
async function localConfig() {
throw new Error("Config failed");
}
async function initializeApp() {
await localConfig();
}
initializeApp().catch(err => console.error(err.stack));In modern Node.js, running this code prints a complete stack trace
showing that initializeApp called localConfig,
even though the execution crossed asynchronous boundaries.
Inspecting Async Stacks with Chrome DevTools
For complex promise chains, command-line stack traces might not provide enough context. You can visually inspect the live asynchronous call stack using the Chrome DevTools inspector.
Start your Node.js application with the inspect flag:
node --inspect-brk index.jsOpen Google Chrome and navigate to
chrome://inspect.Click Inspect next to your Node.js target to open the DevTools window.
In the Sources panel, ensure the Async checkbox in the Call Stack section (on the right sidebar) is checked.
Set a breakpoint inside your promise chain.
When the breakpoint is hit, the Call Stack panel
will display the active synchronous execution stack, followed by an
(async) separator, and then the preceding asynchronous
parent frames that triggered the current chain.
Programmatic Tracing with Async Hooks
If you need to log or audit asynchronous execution paths
programmatically in production, you can use the built-in
async_hooks module. This API tracks the lifetime of
asynchronous resources created inside a Node.js application.
The following example tracks the relationship between asynchronous resources (promises) and their creators:
const async_hooks = require('async_hooks');
const fs = require('fs');
const activeResources = new Map();
const hook = async_hooks.createHook({
init(asyncId, type, triggerAsyncId, resource) {
// Save the relationship between the new resource and what triggered it
activeResources.set(asyncId, { type, triggerAsyncId });
// Write to stdout safely using sync FS operations to avoid recursion
fs.writeSync(1, `Async ID: ${asyncId} (${type}) triggered by ${triggerAsyncId}\n`);
},
destroy(asyncId) {
activeResources.delete(asyncId);
}
});
hook.enable();
// Example promise chain to trace
Promise.resolve()
.then(() => {
return new Promise((resolve) => setTimeout(resolve, 10));
});Using async_hooks allows you to construct a complete
directed graph of how your promises and asynchronous operations are
chained, helping you find unhandled rejections or resource leaks in
complex production environments.