completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Async stack traces

Learn why async stacks break, how V8 rebuilds await chains, what DevTools records, and why await debugs better than then.

By the end, you can
  • 01
    Explain the missing callerSay why a timer callback, a promise callback, or the code after await runs after the function that scheduled it has already returned.
  • 02
    Read rebuilt async linesSpot V8's at async ... lines in a stack, say when they appear, and say why .then callbacks and timers do not get them.
  • 03
    Pick the right debugging toolUse DevTools async call stacks, Node's frame limit, and await where 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.
Definition

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.

Real-life analogyA food-delivery order history

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 await leaves a link between two promises
In real life: A step nobody wrote down leaves a gap
In JavaScript: A .then callback 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.

The timer callback runs after startTimer has returnedPop out in the code editor (opens in a new tab)JavaScript
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.

Left: on line 8 the stack holds startTimer on top of your script. Right: later, inside the timer, the stack holds the timer callback, listOnTimeout, and processTimers. startTimer is not there.On line 8startTimeryour scriptstartTimer is hereLater, in the timertimer callbacklistOnTimeoutprocessTimersno startTimer
On line 8, 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.
Step through: the caller leaves the stack before the later job runs
Step 0 of 9Ready
Your turn: follow the blue line

This is a teaching model. The normal stack empties before the later job runs, but a separate note about the parent can survive.

Running in
  1. script
Next: line 1
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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 outer from stack, just like a return does.
  • Line 9 saves a note in the job: its parent is outer.
  • Line 13 empties stack before 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.

Step through: rebuilding the callers from each await
Step 0 of 7Ready
Your turn: follow the blue line

A teaching model: each await leaves a note, and those notes are enough to rebuild the callers later.

Running in
  1. script
Next: line 1
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(" -> "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Now the real thing. The code below only runs in Node, because it reads process.version. That is why it has no Run button.

Node only: an await chain adds the waiting callersJavaScript
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"));});
Checked in real Node

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.

A window titled error.stack shows four lines: Error: boom, at c, at async b, at async a. Arrows point to them: at c is where it threw, at async b means b was waiting, at async a means a was waiting.error.stackError: boomat cat async bat async awhere it threwb was waitinga was waiting
This is what Node prints for the 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.

Node only: Promise.all gets its own line in the stackJavaScript
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.

Three ways to see which code scheduled the work
Which wayWhere you see itWhat it showsWhat it costs
V8's zero-cost async stackIn 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 stackIn 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 libraryIn 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.

Link later work to where it started with console.createTaskJavaScript
const 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.

Use DevTools for callbacks

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 sets how many frames are keptJavaScript
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.

A tiny long stack trace, made by handPop out in the code editor (opens in a new tab)JavaScript
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.
Real-life analogyA shopping list saved for later

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 as at async plus its name when the other one fails.
  • With .then(): a chain of .then() callbacks often loses the names of the outer functions in error.stack.
Node only: a .then chain loses the outer namesJavaScript
function 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.

Left: the await chain's stack lines are at c, at async b, at async a, so it names b and a. Right: the .then chain's only stack line is a place in the file, line 3, with no name, so there is no b and no a.await chainat cat async bat async anames b and a.then chainat …:3:11line 3, no nameno b, no a
Both chains fail with 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.

Node only: a timer is not an await chainJavaScript
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: timerOuter awaits that promise.
  • Line 12 prints false: timerOuter is not in the stack.
  • Line 13 prints true: the stack shows Node's timer code instead, such as Timeout._onTimeout.

Try it in your own browser. Pick a case and run it to see your browser's real stack text.

Playground: compare the stacks from await, .then, a timer, and Promise.all
The await version and the .then version used by the playgroundJavaScript
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();}
Stack textawait chain
Node 22 result (checked by tests)V8 12.4

Node 22 with V8 12.4 shows `at c`, then `at async b`, then `at async a`.

What your browser showsnot run
  • Run the case to see the checks.
Run the case to see your browser's real stack text.
Step 1 of 4await chain

Pick a case and run it. Then compare your browser's real stack text with the Node 22 result that the tests check.

The code on the left does not change. The right side shows your browser's real 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.

Which way of writing async code leaves which clue in the stack
When you writeWhat the stack shows in V8 12.4What 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.
Will the calling function show up?
  • async b() { await c(); }, and c throws after await 0.
  • await Promise.all([c()]), and the first promise fails after an await.
  • return c().then(value => value), and c fails in a later callback.
  • A setTimeout callback 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).
Try it yourself
0 of 6 correct

Sort each case by what explains the stack you will most likely see.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 errorPop out in the code editor (opens in a new tab)JavaScript
async 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. loadCart and saveReceipt tell you more in a stack than run.
  • Raise Error.stackTraceLimit only 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.
Where this shows up

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(), and Promise.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 .then chain with await." Pick what is clearest. Use await where 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.

Four ideas that are easy to mix up
IdeaWhat it meansHow it is different
Call stackThe functions running right now.It is empty again when the current job finishes.
Async ancestryThe code that scheduled the work that is running now.It has to be rebuilt or recorded. It is not the live call stack.
Stack textThe 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 traceA 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

Exercise 1 · Warm-upPredict the order of the words

Type the three words the starter code prints, in order.

Starter codePop out in the code editor (opens in a new tab)JavaScript
async function demo() {
  console.log("one");
  await 0;
  console.log("three");
}
demo();
console.log("two");

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the timer's stack

    Does the stack inside the timer include startTimer?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function startTimer() {
      setTimeout(() => {
        const stack = new Error("inside timer").stack ?? "";
        console.log(stack.includes("startTimer"));
      }, 0);
    }
    startTimer();

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeFind the keyword the catch needs

      One keyword on line 7 lets the local catch handle the error. Which keyword is it?

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      async function readReceipt() {
        await 0;
        throw new Error("offline");
      }
      async function checkout() {
        try {
          return await readReceipt();
        } catch (error) {
          console.log("handled " + error.message);
        }
      }
      checkout();

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind the DevTools checkbox

        Which Chrome DevTools checkbox shows where a callback was scheduled?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · PracticeName the frame-limit setting

          Which property sets how many stack frames V8 collects?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeUse it in a real app

            Name one habit that would make async bugs easier to find on a checkout page or a page that loads data.

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              Async stack traces quiz · 7 questionsScore: first tries count
              1. Question 1 of 7Why is the original caller missing when a timer callback, a promise callback, or the code after await runs?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this code print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                async function demo() {
                  console.log("one");
                  await 0;
                  console.log("three");
                }
                demo();
                console.log("two");

                Choose an answer to see the explanation.

              3. Question 3 of 7For which cases does V8's documentation promise zero-cost async stack traces?

                Choose an answer to see the explanation.

              4. Question 4 of 7In the await chain code, what does running Node with the --no-async-stack-traces flag remove?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does the Async checkbox in Chrome DevTools do?

                Choose an answer to see the explanation.

              6. Question 6 of 7What does this V8 stack-limit snippet print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                Error.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.

              7. 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(), and Promise.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 await where 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.

              CompleteFrontend Clear concepts. Working examples.