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 callerSay why a timer callback, a promise callback, or the code after
awaitruns after the function that scheduled it has already returned. - 02Read rebuilt async linesSpot V8's
at async ...lines in a stack, say when they appear, and say why.thencallbacks and timers do not get them. - 03Pick the right debugging toolUse DevTools async call stacks, Node's frame limit, and
awaitwhere they really help you find a bug, without slowing your app down.
What an async stack trace is
A stack trace is the list of function calls that led to the current line. JavaScript keeps this list on the call stack. Each function call adds one entry, called a frame. Each return removes that frame.
A normal stack trace answers one question: "Which functions called this one, right now?"
Async code adds a second question. Async code is code that runs later, such as a timer callback or the code after an await. The second question is: "Which code asked for this work to run later?" Asking for work to run later is called scheduling it.
- The answer to the first question is the live call stack: the functions running right now.
- The answer to the second question is the async ancestry: the chain of code that scheduled the work.
An async stack trace is a stack trace that also shows the code that scheduled the current work. That code is no longer running, so the engine or a tool has to rebuild or record the link.
This lesson is about finding bugs, which is called debugging. Other lessons explain how the waiting itself works: the event loop, microtasks, promises, and async and await.
Stack traces and the stack trace API covers Error.stack and how to change its format. The next lesson, Suspending frames: generators & async functions, shows how an await saves a paused function.
Your order history shows ordered, cooking, picked up, and delivered. Different people handled the food at different times. Still, the history lists every step in order. Async stack traces work the same way. The function that started the work has already returned. Tools can still follow the steps that were written down.
- In real life: You place a food order
- In JavaScript: A function asks for work to run later
- In real life: The restaurant moves on to the next order
- In JavaScript: The function returns and leaves the call stack
- In real life: The app writes down each step of the order
- In JavaScript: Each
awaitleaves a link between two promises - In real life: A step nobody wrote down leaves a gap
- In JavaScript: A
.thencallback or a timer may leave no link for the engine to follow
Why the caller goes missing
A normal call stack only lasts while code runs. One piece of work that runs from start to finish is called a job. When a job finishes, the stack is empty again. Control goes back to the host: the program that runs JavaScript, such as the browser or Node.
Later, the host starts the next job with a fresh, empty stack. That job can be a timer callback or the code after an await. The function that scheduled it is not on the stack any more.
function startTimer() { setTimeout(() => { const stack = new Error("inside timer").stack ?? ""; console.log(stack.includes("startTimer")); }, 0);} startTimer();Line 8 calls startTimer. Line 2 asks for a timer, and then startTimer returns. Later, the timer callback runs. Line 3 makes a new error inside it and reads the error's stack text. That text lists the frames on the stack right then.
In Node 22, line 4 prints false: the text does not contain startTimer. Line 8 called it, but that call ended before the timer ran.
startTimer is on the stack. When the timer runs later, the stack starts fresh: the callback sits on Node's own timer code, and startTimer is gone.This is a teaching model. The normal stack empties before the later job runs, but a separate note about the parent 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 this small model. It is a teaching model: a plain array called stack stands for the call stack. Real engines do not use this array, but the idea is the same.
- Line 6 removes
outerfromstack, just like a return does. - Line 9 saves a note in the job: its parent is
outer. - Line 13 empties
stackbefore the later job starts. - Line 16 still prints
outer, because the job kept that note.
So the live stack and the async ancestry are two separate things. The first is gone; the second can be saved.
Zero-cost async stack traces in V8
V8 is the JavaScript engine inside Chrome and Node. V8's stack trace documentation says the --async-stack-traces setting has been on by default since V8 7.3. A setting like this, given when the engine starts, is called a flag.
V8 calls this feature zero-cost. It does not save a copy of the stack each time work is scheduled. Instead, when an error happens, it rebuilds the missing callers. It does this by following the links between promises. A promise is an object for a result that arrives later.
V8's documentation lists the cases it covers:
- lines with
await Promise.all()Promise.any()
First, step through a small model of how V8 follows the awaits back to the callers.
A teaching model: each await leaves a note, and those notes are enough to rebuild the callers later.
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(" -> "));Now the real thing. The code below only runs in Node, because it reads process.version. That is why it has no Run button.
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's test runs this code in Node. Lines 14 and 15 check that it is Node 22 with V8 12.4. Lines 16 and 17 find at async b and at async a in the stack. With the --no-async-stack-traces flag, those two checks print false.
await chain. The at async lines are rebuilt from the awaits: b was waiting for c, and a was waiting for b.Promise.all() takes a list of promises and waits for all of them. When one of them fails, V8 adds a special line for it.
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"));});All three checks print true. Line 14 looks for the special line. In Node 22 it reads at async Promise.all (index 0): the first promise in the list failed. After it come at async b and at async a.
Keep in mind: this is a V8 feature, not a rule of the JavaScript language. Stack text looks different in other engines and versions. The program does the same thing either way. Only the text you read while debugging changes.
Async call stacks in DevTools
DevTools are the developer tools built into the browser. Chrome DevTools has its own way to show async callers. Its async call stack guide shows the Async checkbox. It sits in the Call Stack pane of the Sources panel.
When this checkbox is on, DevTools writes down where each piece of async work was scheduled. Later, when your code pauses inside a callback, DevTools can show where that callback came from. This works for timers, network requests (fetch and XHR), promises, animation frames, mutation observers, and more. V8's zero-cost error.stack does not cover most of these.
The table below compares three ways to see async callers. Each row is one way: where you see it, what it shows, and what it costs.
| Which way | Where you see it | What it shows | What it costs |
|---|---|---|---|
| V8's zero-cost async stack | In error.stack, in Chrome and Node. | V8 rebuilds the callers for await, Promise.all(), and Promise.any(). | Nothing extra to set up. It has been on by default since V8 7.3, through the --async-stack-traces flag. |
| DevTools async call stack | In the Sources panel and the Console, while DevTools is open. | DevTools writes down where timers, network requests, promises, animation frames, and observers were scheduled. | It covers callbacks too. Writing everything down slows the page a little, and it only works inside DevTools. |
| Long stack trace library | In libraries, your own wrappers, or Node's async_hooks. | The code saves the stack when work is scheduled and adds it to the later error. | It is helpful for debugging, but saving every stack costs time and memory. |
Your own code can help DevTools too. A scheduler is code that decides when other work runs; frameworks often have one. The code below links later work to the place it started, using console.createTask.
console.createTaskJavaScriptconst task = console.createTask("checkout submit"); button.addEventListener("click", () => { setTimeout(() => { task.run(() => { saveOrder(); }); }, 0);});The Chrome Console API describes console.createTask(name).
- Line 1 makes a task object. It remembers the stack at that moment.
- Line 5 runs the later work through
task.run(fn). - DevTools then links the later work on line 6 back to line 1.
button and saveOrder stand for your own page's button and function.
Is the bug inside setTimeout, an event listener, or a custom scheduler? Then DevTools async call stacks are often the clearer tool. V8's zero-cost stack is great for await chains, but it does not record every step.
Long stack traces in Node.js
Node runs JavaScript with V8. So Node gets V8's zero-cost async stack traces for the same promise cases.
Node also has Error.stackTraceLimit. It sets how many frames V8 collects for each new error. The default is 10. Raise it when a long chain of calls gets cut off before the frame you need.
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);}Line 1 sets the limit to 2. Lines 9 to 11 count the stack lines that start with at. Line 12 prints true: no more than two frames were kept.
Before V8 had async frames, Node projects used long stack trace tools. These came from libraries, not from Node itself. The idea is simple:
- When work is scheduled, save the stack right away.
- When that work later throws an error, add the saved stack to the error.
Node also has a low-level API, async_hooks, that can track async work. Node's own docs strongly advise against using async_hooks unless you really need it.
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");});- Line 2 saves the stack while the work is being scheduled.
- Line 16 throws an error inside the later callback.
- Lines 7 to 9 join three things: the first line of the new error, a
--- async boundary ---line, and one line from the saved stack. - Line 10 prints
true: the joined text has the boundary line in it.
You write a shopping list before you leave home. At the shop, it tells you what you planned, even though the planning is over. Long stack traces use the same idea. The saved stack helps you find the bug. The program itself does not need it to run.
- In real life: You write a shopping list before you leave home
- In JavaScript: A long stack trace tool saves the stack when work is scheduled
- 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 adds the saved stack to the later error
- In real life: Writing down every detail takes time
- In JavaScript: Saving every stack costs time and memory
await vs then: which is easier to debug
await and .then() both wait for promises. They leave different clues in the stack.
- With
await: in V8 12.4, an async function that awaits another one shows up asat asyncplus its name when the other one fails. - With
.then(): a chain of.then()callbacks often loses the names of the outer functions inerror.stack.
.then chain loses the outer 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 "));});Line 3 throws inside a .then() callback. Lines 15 and 16 both print false: b and a are not in the stack.
Error: boom. Below that line, the await chain names b and a. The .then chain shows only the place where the callback threw.Timers are different again. A timer callback is not part of an await chain, even when an async function waits for it.
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));});- Line 3 makes the error inside a timer callback.
- Line 7:
timerOuterawaits that promise. - Line 12 prints
false:timerOuteris not in the stack. - Line 13 prints
true: the stack shows Node's timer code instead, such asTimeout._onTimeout.
Try it in your own browser. Pick a case and run it to see your browser's real stack text.
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 with V8 12.4 shows `at c`, then `at async b`, then `at async a`.
not run- Run the case to see the checks.
Run the case to see your browser's real stack text.Pick a case and run it. Then compare your browser's real stack text with the Node 22 result that the tests check.
error.stack, which can look different in Node, Firefox, and Safari. Nothing you type is run.The table below sums it up. Each row is one way to write the code, the clue it leaves in V8 12.4, and what to do about it.
| When you write | What the stack shows in V8 12.4 | What to do |
|---|---|---|
await child() | When child fails after an await, V8 can add at async parent. | Use it when you want code that reads top to bottom and a stack that names the callers. |
return child().then(...) | The later .then callback is often the only useful line. The outer function names may be missing. | It is fine for chaining, but harder to debug when something deep inside fails. |
setTimeout(callback) | The callback runs as a new job from the host. A normal error.stack starts inside the callback and the timer code. | Use DevTools async stacks, or save the stack yourself, if you need to know where the timer was set. |
await Promise.all([child()]) | V8 can show at async Promise.all (index 0), then the waiting callers. | Helpful when you need to know which promise in the list failed. |
async b() { await c(); }, andcthrows afterawait 0.await Promise.all([c()]), and the first promise fails after anawait.return c().then(value => value), andcfails in a later callback.- A
setTimeoutcallback throws in Node, with no DevTools open. - Chrome's Sources panel pauses inside a timer callback, with the Async checkbox on.
- A framework runs work with
console.createTask('save').run(callback).
Sort each case by what explains the stack you will most likely see.
Practical debugging habits
Use async and await when they make the code read from top to bottom. They also keep function names in the stack. Keep .then() where it makes the code clearer. Just know that the stack may show fewer names.
return await lets the local catch see the errorasync 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 uses return await on purpose. The await waits for the promise inside the try block. When the promise fails, the error is thrown right there, on line 8. The catch on line 9 handles it, and line 10 prints checkout saw payment failed.
Without await, the failed promise would leave the try block first, and this catch would never see it. The promise errors lesson explains this rule in more depth.
These habits make async bugs easier to find:
- Give async functions clear names.
loadCartandsaveReceipttell you more in a stack thanrun. - Raise
Error.stackTraceLimitonly while debugging. More frames cost time on every error, so don't raise it in code that runs on every request. - When you wrap one error in a new one, pass the first as
cause:new Error("Checkout failed", { cause: error }). The new error then points back to the first one. - For bugs in callbacks, turn on DevTools async stacks. Measure your app's speed before you add a long stack trace library.
Checkout pages, background sync, retry queues, animation callbacks, analytics beacons (small reports sent to a server), and server request handlers all run async work. Good async stack traces help you find the code that scheduled the work that failed.
Common misconceptions
- "The old stack is still there somewhere." It is not. The old stack is gone. Async frames are rebuilt or recorded to help you debug.
- "Zero-cost means every async callback is covered." V8 lists only
await,Promise.all(), andPromise.any(). Timers and other callbacks are not covered. - "Stack text looks the same everywhere." The JavaScript standard does not define the text of
Error.stack. Each engine and tool picks its own format. - "Long stack traces are free." Saving a stack each time work is scheduled costs time and memory, especially in busy Node servers.
- "Always rewrite every
.thenchain withawait." Pick what is clearest. Useawaitwhere it makes the code easier to read and the stack easier to follow.
Some ideas in this lesson sound alike. Each row below names one idea, says what it means, and how it is different from the others.
| Idea | What it means | How it is different |
|---|---|---|
| Call stack | The functions running right now. | It is empty again when the current job finishes. |
| Async ancestry | The code that scheduled the work that is running now. | It has to be rebuilt or recorded. It is not the live call stack. |
| Stack text | The text of error.stack, written by an engine or a tool. | The JavaScript standard does not define it, so it differs between engines. |
| Long stack trace | A stack joined from an early saved stack and a later error, to help people debug. | It costs time and memory, and your program should not depend on it. |
Practice exercises
Type the three words the starter code prints, in order.
async function demo() {
console.log("one");
await 0;
console.log("three");
}
demo();
console.log("two");The output is one, two, then three. demo() prints one and pauses at await. The caller then prints two. Later, demo carries on and prints three.
Does the stack inside the timer include startTimer?
function startTimer() {
setTimeout(() => {
const stack = new Error("inside timer").stack ?? "";
console.log(stack.includes("startTimer"));
}, 0);
}
startTimer();The answer is false. In Node, the normal stack inside the timer starts at the timer callback, on top of Node's own timer code. startTimer returned long before.
One keyword on line 7 lets the local catch handle the error. Which keyword is it?
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();It is await, in return await readReceipt(). The failed promise turns into a thrown error on line 7, inside the try, so the local catch prints handled offline.
Which Chrome DevTools checkbox shows where a callback was scheduled?
Turn on the Async checkbox in the Call Stack pane. DevTools then writes down where async work was scheduled.
Which property sets how many stack frames V8 collects?
Use Error.stackTraceLimit in Chrome or Node when you need more or fewer frames while debugging.
Name one habit that would make async bugs easier to find on a checkout page or a page that loads data.
Good answers: use await in functions that wrap other async functions, turn on DevTools async stacks for callbacks, keep the first error in cause, raise Error.stackTraceLimit only while debugging, and measure before you add a long stack trace library to busy code.
Check your understanding
Question 1 of 7Why is the original caller missing when a timer callback, a promise callback, or the code after
awaitruns?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 7For which cases does V8's documentation promise zero-cost async stack traces?
Choose an answer to see the explanation.
Question 4 of 7In the await chain code, what does running Node with the
--no-async-stack-tracesflag remove?Choose an answer to see the explanation.
Question 5 of 7What does the Async checkbox in Chrome DevTools 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 way of writing a function that wraps another usually gives a clearer stack in V8?
Choose an answer to see the explanation.
Key takeaways
- The live call stack is empty again when a job finishes. Async ancestry, the code that scheduled the work, is a separate thing.
- V8's zero-cost async stack traces are on by default. They rebuild the callers for
await,Promise.all(), andPromise.any(). - Timers, event listeners, and many
.then()callbacks need DevTools or your own saved stack to show where they were scheduled. - Node gets V8's async frames, has
Error.stackTraceLimit, and can use long stack trace libraries, with care. - Use
awaitwhere it keeps code readable and gives better stack clues. Don't write code just to change the stack text.
One-line summary: an async stack trace is not the old stack kept forever. It is the best story the engine or a tool can rebuild or record about why later work is running.
Next, Suspending frames: generators & async functions explains how JavaScript saves a paused function so it can carry on later.