Async stack traces
Learn why async stacks break, how V8 rebuilds await chains, what DevTools records, and why await debugs better than then.
- 01Explain the missing callerDescribe why a timer, promise job, or awaited continuation resumes after the synchronous caller has already returned.
- 02Read reconstructed async framesIdentify V8's zero-cost
at async ...frames, where they appear, and why.thencallbacks and timers are different. - 03Choose debugging tools deliberatelyUse DevTools async call stacks, Node stack limits, and
awaitstyle where they improve real diagnostics without adding noisy overhead.
Async stack traces in one picture
A normal stack trace answers “who called this function right now?” Async JavaScript adds a harder question: “who scheduled the work that is running now?” The first question is the live call stack. The second is async ancestry.
An async stack trace is a debugging stack that crosses an asynchronous boundary by reconstructing or recording the scheduling relationship between an earlier job and the later callback, promise job, or async-function continuation.
This lesson stays focused on diagnostics. The event loop, microtasks, promises, and async and await explain the scheduling rules. Stack traces and the stack trace API covers Error.stack, Error.captureStackTrace, and custom formatting. The next lesson, Suspending frames: generators & async functions, goes deeper on how an await saves a function's state.
Your order history can show ordered, cooking, picked up, and delivered, even though different people handled the food at different times. Async stacks are similar: the live caller is gone, but tools can sometimes follow the recorded hand-offs.
- In real life: You place a food order
- In JavaScript: A function schedules async work
- In real life: The restaurant moves to the next order
- In JavaScript: The synchronous caller returns and leaves the stack
- In real life: The app records each order step
- In JavaScript: Each
awaitleaves a promise relationship - In real life: A missing step leaves a gap
- In JavaScript: A
.thencallback or timer may not give V8 an async frame
Where the analogy stops: An order history is written for customers. JavaScript engines only reconstruct async frames for relationships they can prove.
Why async stacks break
A synchronous stack is short-lived. When line 8 schedules a timer or line 6 reaches an await, the current job eventually returns to the host. Later, the event loop or microtask queue starts a new job from a fresh entry point. The old caller is not on the machine stack anymore.
function startTimer() { setTimeout(() => { const stack = new Error("inside timer").stack ?? ""; console.log(stack.includes("startTimer")); }, 0);} startTimer();In Node 22, this prints false: the stack captured inside the timer does not include startTimer. Line 2 is the later callback. Line 8 scheduled it, but that scheduling call already finished.
This is a teaching model: the synchronous stack disappears before the later job runs, while a separate async breadcrumb can survive.
script
const queue = [];function outer() { stack.push("outer"); schedule("resume outer"); stack.pop();}function schedule(label) { queue.push({ label, asyncParent: "outer" });}outer();const job = queue.shift();stack.length = 0;stack.push("microtask");console.log(job.label);console.log(job.asyncParent);Step through the teaching model. Line 6 returns from outer, so the live stack loses that frame. Line 16 can still print a saved async parent because the job carried a breadcrumb. Real engines do not use this array, but the distinction is accurate: live stack and async ancestry are different facts.
Zero-cost async stack traces
V8's stack trace API documentation says --async-stack-traces is on by default since V8 7.3. The feature is called zero-cost because V8 can reconstruct supported async frames from promise relationships instead of recording every scheduling stack. The supported cases documented there are await locations, Promise.all(), and Promise.any().
Step through a teaching model of how an await chain leaves enough promise links for a later stack trace.
script
function rememberAwait(caller, awaited) { promiseChain.push(caller + " awaits " + awaited);}function c() { promiseChain.push("c throws after await"); return "Error: boom";}function b() { rememberAwait("b", "c"); return c();}function a() { rememberAwait("a", "b"); return b();}const error = a();const asyncFrames = promiseChain .filter((step) => step.includes("awaits")) .reverse();console.log(error);console.log(asyncFrames.join(" -> "));async function c() { await 0; throw new Error("boom");}async function b() { await c();}async function a() { await b();} a().catch((error) => { const stack = String(error.stack); console.log(/^v22\./.test(process.version)); console.log(/^12\.4\./.test(process.versions.v8)); console.log(stack.includes("at async b")); console.log(stack.includes("at async a"));});The lesson test runs this in a child Node process. On Node 22 with V8 12.4, the first two lines prove the runtime family, then the stack includes at async b and at async a. Running the same code with --no-async-stack-traces makes those two checks false.
async function c() { await 0; throw new Error("boom");}async function b() { await Promise.all([c()]);}async function a() { await b();} a().catch((error) => { const stack = String(error.stack); console.log(stack.includes("at async Promise.all")); console.log(stack.includes("at async b")); console.log(stack.includes("at async a"));});Notice the scope: this is an implementation feature in V8, not a JavaScript semantic guarantee. Stack strings vary across engines and versions. The program's behavior is still the same promise rejection; the debugging story is what changes.
Async call stacks in DevTools
Chrome DevTools has a different tool-level feature. Its async call stack guide shows the Sources panel's Async checkbox in the Call Stack pane. When DevTools records async operations, it can link a later callback back to the scheduling site for timers, XHR or fetch work, promises, animation frames, mutation observers, and more. That helps for cases V8's zero-cost error.stack will not cover.
| Mechanism | Where you see it | What it captures | Cost model |
|---|---|---|---|
| V8 zero-cost async stack | error.stack on V8/Node | Reconstructs supported promise ancestry: await, Promise.all(), and Promise.any(). | On by default since V8 7.3 via --async-stack-traces; no DevTools needed. |
| DevTools async call stack | Sources and Console while DevTools is open | Records scheduling stacks for browser async primitives such as timers, XHR/fetch, promises, animation frames, and observers. | Useful for callbacks too, but recording history has debugging overhead and is a tool feature. |
| Userland long stack trace | Libraries, wrappers, or async_hooks | Captures stacks at scheduling time and stitches them onto later errors. | Powerful for diagnostics, but can cost CPU/memory and may change async behavior if overused. |
console.createTaskJavaScriptconst task = console.createTask("checkout submit"); button.addEventListener("click", () => { setTimeout(() => { task.run(() => { saveOrder(); }); }, 0);});The Chrome Console API documents console.createTask(name) as a way to associate the current stack with a task object, then run later work through task.run(fn). Frameworks and custom schedulers can use that link so DevTools shows better async stacks.
If the bug is inside setTimeout, an event listener, or a custom scheduler, DevTools async call stacks are often the clearer tool. V8's zero-cost stack is excellent for await chains, but it is not a complete history recorder.
Long stack traces in Node.js
Node uses V8, so modern Node inherits V8's zero-cost async stack traces for supported promise relationships. Node also exposes Error.stackTraceLimit, which controls how many frames V8 collects in the current context. That is useful when a deep call chain is clipped before the interesting frame.
Error.stackTraceLimit = 2;function one() { two(); }function two() { three(); }function three() { throw new Error("short"); } try { one();} catch (error) { const frameCount = String(error.stack) .split("\n") .filter((line) => line.trim().startsWith("at ")).length; console.log(frameCount <= 2);}Before V8's built-in async frames, Node projects often used userland “long stack trace” tools. The basic trick is to capture a stack at scheduling time and append it when the later error happens. Libraries can do this more completely, and Node's async_hooks API can track async resources, but Node's docs strongly discourage casual async_hooks use because it is a low-level API.
function scheduleWithCapturedStack(work) { const scheduledStack = new Error("Scheduled here").stack ?? ""; setTimeout(() => { try { work(); } catch (error) { const combined = String(error.stack).split("\n")[0] + "\n--- async boundary ---\n" + scheduledStack.split("\n")[1].trim(); console.log(combined.includes("async boundary")); } }, 0);} scheduleWithCapturedStack(() => { throw new Error("lost parcel");});Save a shopping list before you leave home. Later, it tells you what you planned even though the planning is over. Long async stacks use the same idea, but the saved details are for developers, not for program correctness.
- In real life: You save a shopping list before leaving home
- In JavaScript: A long-stack tool captures the scheduling stack
- In real life: You read the list later at the shop
- In JavaScript: The callback or promise job runs later
- In real life: The list reminds you what you planned
- In JavaScript: The tool stitches earlier context onto the error
- In real life: Writing down every detail takes time
- In JavaScript: Capturing every stack can cost CPU and memory
Where the analogy stops: A shopping list is for buying things. A long stack trace is debugging data, so never make program behavior depend on its text.
await vs then for debuggability
await and .then() both compose promises, but they leave different debugging evidence. In V8 12.4, an async wrapper that actually awaits a child can appear as at async wrapper after the child rejects. A chain that bounces through .then() callbacks often loses those outer helper names in error.stack.
.then does not add the outer helper namesJavaScriptfunction c() { return Promise.resolve().then(() => { throw new Error("boom"); });}function b() { return c().then((value) => value);}function a() { return b().then((value) => value);} a().catch((error) => { const stack = String(error.stack); console.log(stack.includes("at b ")); console.log(stack.includes("at a "));});function waitForTimer() { return new Promise((_, reject) => { setTimeout(() => reject(new Error("boom")), 0); });}async function timerOuter() { await waitForTimer();} timerOuter().catch((error) => { const stack = String(error.stack); console.log(stack.includes("timerOuter")); console.log(/Timeout|setTimeout/.test(stack));});async function awaitVersion() { async function c() { await 0; throw new Error("boom"); } async function b() { await c(); } async function a() { await b(); } await a();} function thenVersion() { function c() { return Promise.resolve().then(() => { throw new Error("boom"); }); } function b() { return c().then((value) => value); } function a() { return b().then((value) => value); } return a();}V8 12.4Node 22 / V8 12.4 shows `c`, then `at async b`, then `at async a`.
not run- Run the case to inspect markers.
Run the case to capture your browser's real stack string.Choose a case, run it, and compare your browser's real stack string with the Node 22 result verified by tests.
error.stack, which can differ from Node, Firefox, and Safari. No user input is evaluated.| Pattern | Stack clue in V8 12.4 | Practical advice |
|---|---|---|
await child() | V8 can add at async parent when the child rejects after an async boundary. | Best when you want top-to-bottom code and helpful stack ancestry. |
return child().then(...) | The later .then callback often becomes the top useful frame; outer helper names may disappear. | Fine for composition, but less friendly when debugging deep failures. |
setTimeout(callback) | The callback runs from a host task, so ordinary error.stack starts inside the callback/timer machinery. | Use DevTools async stacks or explicit context if the scheduling site matters. |
await Promise.all([child()]) | V8 can show at async Promise.all (index 0) plus async callers. | Useful when the failing branch inside a batch matters. |
async b() { await c(); }andcthrows afterawait 0.await Promise.all([c()])where the first branch rejects after an await.return c().then(value => value)aftercrejects in a later microtask.- A
setTimeoutcallback throws in Node without DevTools attached. - Chrome Sources pauses inside a timer callback with the Async checkbox enabled.
- A framework wraps work with
console.createTask('save').run(callback).
Sort each case by the mechanism that explains the stack you are likely to see.
Practical debugging habits
Prefer async/await when it makes the control flow read from top to bottom and when wrapper functions matter in diagnostics. Keep .then() where it makes composition clearer, but know what you trade away in stack readability.
return await when the local catch should see the rejectionasync function fetchReceipt() { await 0; throw new Error("payment failed");} async function checkout() { try { return await fetchReceipt(); } catch (error) { console.log("checkout saw " + error.message); }} checkout();Line 8 is intentionally return await. Without await, the rejected promise would leave the try block before the local catch could handle it. The promise errors lesson goes deeper on that rule.
- Keep wrapper names meaningful:
loadCartandsaveReceiptare better stack clues thanrun. - Set
Error.stackTraceLimitdeliberately in diagnostics, not in every hot request path. - Use
error.causewhen wrapping failures so a higher-level error points back to the original one. - Open DevTools async stacks for callback-heavy bugs; use production measurements before adding long-stack libraries.
Checkout flows, background sync, retry queues, animation callbacks, analytics beacons, and server request handlers all cross async boundaries. Good async stacks help you find the operation that scheduled the failing work.
Common misconceptions
- “The stack never really disappeared.” It did. Async frames are reconstructed or recorded debugging context.
- “Zero-cost means every async callback is covered.” V8 documents
await,Promise.all(), andPromise.any(), not arbitrary timers. - “Stack strings are standardized.” JavaScript does not standardize
Error.stacktext; engines and tools choose formats. - “Long stacks are free.” Capturing scheduling stacks can cost CPU and memory, especially in busy Node services.
- “Always rewrite every chain to await.” Prefer clarity. Use
awaitwhere it improves readability and diagnostics.
| Idea | What it means | Do not confuse it with |
|---|---|---|
| Synchronous call stack | What is physically running right now. | It ends when the current job returns to the host. |
| Async ancestry | Why this later job exists. | It must be reconstructed or recorded; it is not the live machine stack. |
| Stack string | A debugging representation chosen by an engine or tool. | It is not standardized JavaScript semantics. |
| Long stack trace | A stitched history useful for people. | It may have overhead and should not become business logic. |
Practice exercises
Type the three words printed by the starter code.
async function demo() {
console.log("one");
await 0;
console.log("three");
}
demo();
console.log("two");The output is one, two, then three. The body reaches await, returns to the caller, and resumes later.
Does the timer stack include startTimer?
function startTimer() {
setTimeout(() => {
const stack = new Error("inside timer").stack ?? "";
console.log(stack.includes("startTimer"));
}, 0);
}
startTimer();In Node's ordinary stack, the answer is false. The timer callback starts later from timer machinery.
Which keyword is essential on line 8?
async function readReceipt() {
await 0;
throw new Error("offline");
}
async function checkout() {
try {
return await readReceipt();
} catch (error) {
console.log("handled " + error.message);
}
}
checkout();async function readReceipt() {
await 0;
throw new Error("offline");
}
async function checkout() {
try {
return await readReceipt();
} catch (error) {
console.log("handled " + error.message);
}
}
checkout();Use return await readReceipt(). The rejection turns into a throw at that line, so the local catch can log it.
Which Chrome DevTools checkbox helps with callback ancestry?
Turn on the Async checkbox in the Call Stack pane to record async ancestry while debugging.
Which property controls how many stack frames V8 collects?
Use Error.stackTraceLimit in V8/Node contexts when diagnostics need more or fewer frames.
Name one habit that would improve async debugging in a checkout or data-loading flow.
Good answers include preferring await for wrappers, using DevTools async stacks for callbacks, preserving error.cause, setting Error.stackTraceLimit deliberately, and avoiding long-stack tools on hot paths until measurements justify them.
Check your understanding
Question 1 of 7Why does the synchronous caller disappear after an
await, timer, or promise callback resumes?Choose an answer to see the explanation.
Question 2 of 7What does this code print?
Read the code, then predictasync function demo() { console.log("one"); await 0; console.log("three"); } demo(); console.log("two");Choose an answer to see the explanation.
Question 3 of 7Which relationships does V8 document as zero-cost async stack trace support?
Choose an answer to see the explanation.
Question 4 of 7What does running Node with
--no-async-stack-tracesremove from the await-chain proof?Choose an answer to see the explanation.
Question 5 of 7What does the Chrome DevTools Async checkbox do?
Choose an answer to see the explanation.
Question 6 of 7What does this V8 stack-limit snippet print?
Read the code, then predictError.stackTraceLimit = 1; function a() { b(); } function b() { throw new Error("short"); } try { a(); } catch (error) { console.log(String(error.stack).split("\n").filter((line) => line.trim().startsWith("at ")).length); }Choose an answer to see the explanation.
Question 7 of 7Which style usually gives a clearer V8 async stack for a wrapper function?
Choose an answer to see the explanation.
Key takeaways
- The live synchronous stack ends when a job returns; async ancestry is separate debugging evidence.
- V8's zero-cost async stacks are on by default and reconstruct supported
await,Promise.all(), andPromise.any()relationships. - Timers, event listeners, and many
.then()callbacks need DevTools recording or explicit context if you want their scheduling site. - Node inherits V8's async frames, exposes
Error.stackTraceLimit, and can use userland long stacks with care. - Prefer
awaitwhere it keeps code readable and improves stack clues; do not optimize for stack text alone.
One-line summary: async stack traces are not the old stack preserved forever; they are the best reconstructed or recorded story of why later work is running. Next, Suspending frames: generators & async functions explains how JavaScript saves a paused function's state so it can resume later.