cf.completefrontendCode editorOpen lab
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 callerDescribe why a timer, promise job, or awaited continuation resumes after the synchronous caller has already returned.
  • 02
    Read reconstructed async framesIdentify V8's zero-cost at async ... frames, where they appear, and why .then callbacks and timers are different.
  • 03
    Choose debugging tools deliberatelyUse DevTools async call stacks, Node stack limits, and await style 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.

Definition

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.

Real-life analogyA food-delivery order history

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 await leaves a promise relationship
In real life: A missing step leaves a gap
In JavaScript: A .then callback 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.

A timer callback starts without the scheduling framePop 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();

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.

Step through the stack disappearing at an async boundary
Step 0 of 9Ready
Your turn: follow the blue line

This is a teaching model: the synchronous stack disappears before the later job runs, while a separate async breadcrumb can survive.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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);
CallStoreChangeResultRun = next line. 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 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 promise-chain reconstruction
Step 0 of 7Ready
Your turn: follow the blue line

Step through a teaching model of how an await chain leaves enough promise links for a later stack trace.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
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(" -> "));
CallStoreChangeResultRun = next line. 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.
Node-only proof: an await chain adds async parentsJavaScript
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"));});
Verified runtime fact

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.

Node-only proof: Promise.all gets a special async frameJavaScript
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.

Three ways an async stack can be made visible
MechanismWhere you see itWhat it capturesCost model
V8 zero-cost async stackerror.stack on V8/NodeReconstructs 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 stackSources and Console while DevTools is openRecords 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 traceLibraries, wrappers, or async_hooksCaptures 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.
DevTools async stack tagging with console.createTaskJavaScript
const 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.

Use DevTools for callbacks

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.

Node/V8 stack depth controlJavaScript
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.

A tiny manual long-stack modelPop 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");});
Real-life analogyA shopping list saved for later

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.

Node-only proof: .then does not add the outer helper 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 "));});
Node-only proof: timers are not await chainsJavaScript
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));});
Playground: compare await, then, timer, and Promise.all stacks
Two contrasting functions 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();}
Captured stackawait chain
Verified Node 22 resultV8 12.4

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

Your browser markersnot run
  • Run the case to inspect markers.
Run the case to capture your browser's real stack string.
Step 1 of 4await chain

Choose a case, run it, and compare your browser's real stack string with the Node 22 result verified by tests.

The left pane is fixed source. The right pane shows your browser's real error.stack, which can differ from Node, Firefox, and Safari. No user input is evaluated.
Which style leaves which debugging clue?
PatternStack clue in V8 12.4Practical 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.
Will the async parent appear?
  • async b() { await c(); } and c throws after await 0.
  • await Promise.all([c()]) where the first branch rejects after an await.
  • return c().then(value => value) after c rejects in a later microtask.
  • A setTimeout callback 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).
Try it yourself
0 of 6 correct

Sort each case by the mechanism that explains the stack you are likely to see.

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

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.

Use return await when the local catch should see the rejectionPop 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 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: loadCart and saveReceipt are better stack clues than run.
  • Set Error.stackTraceLimit deliberately in diagnostics, not in every hot request path.
  • Use error.cause when 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.
Where this shows up

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(), and Promise.any(), not arbitrary timers.
  • “Stack strings are standardized.” JavaScript does not standardize Error.stack text; 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 await where it improves readability and diagnostics.
Similar ideas that are easy to mix up
IdeaWhat it meansDo not confuse it with
Synchronous call stackWhat is physically running right now.It ends when the current job returns to the host.
Async ancestryWhy this later job exists.It must be reconstructed or recorded; it is not the live machine stack.
Stack stringA debugging representation chosen by an engine or tool.It is not standardized JavaScript semantics.
Long stack traceA stitched history useful for people.It may have overhead and should not become business logic.

Practice exercises

Exercise 1 · Warm-upPredict an async order

Type the three words printed by the starter code.

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 missing timer parent

    Does the timer stack 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 · PracticeFix the local catch

      Which keyword is essential on line 8?

      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 · PracticeChoose the DevTools switch

        Which Chrome DevTools checkbox helps with callback ancestry?

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

          Exercise 5 · PracticeName the stack-depth setting

          Which property controls how many stack frames V8 collects?

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

            Exercise 6 · ChallengeApply it to a real app

            Name one habit that would improve async debugging in a checkout or data-loading flow.

            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 does the synchronous caller disappear after an await, timer, or promise callback resumes?

                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 7Which relationships does V8 document as zero-cost async stack trace support?

                Choose an answer to see the explanation.

              4. Question 4 of 7What does running Node with --no-async-stack-traces remove from the await-chain proof?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does the Chrome DevTools Async checkbox 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 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(), and Promise.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 await where 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.

              CompleteFrontend Clear concepts. Working examples.