skip to content

A callback passed to `setTimeout` throws, and the stack trace contains no frames from the function that scheduled it. Why does that happen in JavaScript, and how do you preserve the calling context?

level: seniorimportance: should knowfreq 45%

answer

  1. a snapshot, not a history
  2. the scheduler already returned
  3. frames popped before the callback ran
  4. await is stitched, callbacks are not
  5. capture context while it still exists

basics

~20 s

The scheduling function returned long before the callback ran, so its frames were already popped off the call stack and cannot appear in a trace captured later. Preserve context by constructing an error at schedule time and attaching it as the cause of whatever the callback throws.

solid answer

~50 s

A stack trace is a snapshot of the frames currently on the call stack when the error is constructed. JavaScript runs each turn to completion: `schedule()` pushes the timer and returns, its frame pops, and only afterwards does the engine pick the callback up as a fresh task with an essentially empty stack beneath it. There is nothing left to report. Modern V8 does stitch traces across `await` in async functions — that is the zero-cost async stack traces feature — but it only covers awaited promises inside async functions, not plain callbacks handed to timers, emitters or promise executors. The practical fixes are to capture the context yourself: build an `Error` (or just its `stack`) before scheduling, close over it, and when the callback fails rethrow with `{ cause: scheduledAt }`; or convert callback APIs into promises and `await` them so the engine's own stitching applies.

code

javascript · 18 lines
javascript
function scheduleWork(work, ms) {
  const site = new Error('scheduled here'); // frames still live
  setTimeout(() => {
    try {
      work();
    } catch (e) {
      const wrapped = new Error('scheduled work failed', { cause: e });
      console.error(wrapped.message);
      console.error('failure:', e.stack);
      console.error('queued at:', site.stack); // the frames that were lost
    }
  }, ms);
}

function caller() {
  scheduleWork(() => { throw new Error('boom'); }, 0);
}
caller();

go deeper

for a junior

Know that code inside a setTimeout callback runs after the scheduling function has already returned, so its trace cannot show the scheduler. Also know that wrapping the setTimeout call in try/catch does not catch what the callback throws.

for a middle

Explain it mechanically: the trace is a snapshot of live frames, run-to-completion means the scheduler's frame was popped before the callback ran, and the fix is to capture context into an object that outlives the stack.

for a senior

Demonstrate diagnosis under production conditions — distinguishing frame-limit truncation from a genuine async boundary, knowing that await stitching covers awaited promises only, and applying context capture at dispatch boundaries where its cost is affordable.

for a principal

Own traceability as a system property: where correlation identifiers are injected, which boundaries pay to capture context, and how much diagnosability the platform guarantees before an incident forces the question.

## Why the frames are gone A stack trace is not a history of the program — it is a snapshot of the frames that are live at one instant. JavaScript's execution model makes that decisive: the engine runs the current job to completion, and only when the call stack is empty does it pick up the next scheduled task. ```js function schedule() { setTimeout(() => { throw new Error('boom'); }, 0); } schedule(); ``` By the time the arrow function runs, `schedule` has returned and its frame is gone. The callback is invoked from an empty stack, so the trace reads roughly: ``` Error: boom at Timeout._onTimeout (/app/a.js:3:11) ...runtime internals... ``` Nothing about `schedule` survives, because nothing about `schedule` still exists. This is not a bug or a missing feature — it falls directly out of run-to-completion plus "capture happens at construction". ## What the engine does stitch, and what it does not Modern V8 implements async stack traces: when an async function `await`s a promise, the engine keeps enough information to splice the awaiting frames back into a trace produced later in the continuation. So this generally *does* give a useful trace naming both functions: ```js async function outer() { await inner(); } async function inner() { throw new Error('boom'); } ``` The limits are the interesting part. The stitching applies to awaited promises within async functions. It does not extend to: - a plain function handed to `setTimeout` or `setInterval`; - a listener registered on an event emitter and invoked much later; - a callback passed into a promise executor and stored for later; - a callback-style API that never became a promise at all. In every one of those, the scheduling frames were unwound before the callback ran, and no continuation relationship was recorded. Rewriting a callback chain into `async`/`await` is therefore not only a readability change: it hands the engine the information it needs to produce a joined trace. ## Preserving context yourself The general technique is to capture the context while it still exists and carry it forward as data. ```js function schedule(work, ms) { const scheduledAt = new Error('scheduled here'); // captured NOW setTimeout(() => { try { work(); } catch (e) { throw new Error(`scheduled work failed: ${e.message}`, { cause: e, }); } }, ms); return scheduledAt; } ``` Two variants are worth distinguishing. Attaching the *caught* error as `cause` links the failure to its own construction site — useful, but it does not recover the scheduler's frames. To recover those you must close over the pre-built `scheduledAt` error and surface its `stack` alongside the failure, because that object is the only remaining record of a stack that no longer exists. A wrapper that logs both strings gives a reader the full picture: where the work was queued, and where it blew up. The cost is why this is not on by default. Constructing an error captures frames, and doing it on every scheduled unit of work in a hot loop is measurable. Real systems apply it selectively: at the few boundaries where async work is dispatched, not at every call site. ## Diagnosing when the trace is thin When a production trace looks truncated, work through the causes in order: 1. **Frame limit.** V8 captures at most `Error.stackTraceLimit` frames, default 10. Deep framework chains hit this. Raise it temporarily while investigating. 2. **Async boundary.** The trace starts at a callback with runtime internals beneath it — the signature of the loss described above. 3. **Late construction.** The error was created in a generic handler rather than at the failure point, so it faithfully reports a place you do not care about. Construct errors where the failure is detected. 4. **Rethrown wrapper without a cause.** Someone caught, discarded, and threw a fresh error; the inner frames were never linked. Wrapping with `{ cause }` is the fix. ## The judgment an interviewer is listening for A strong answer separates the mechanism from the workaround. The mechanism is that stacks are frame snapshots and asynchronous work runs on a stack that no longer contains its scheduler. The workaround is to move the context out of the stack and into the heap — a captured error object, a request identifier threaded through, a correlation id in the log line. A weaker answer reaches straight for "turn on async stack traces" as if it were a switch that recovers everything; it recovers awaited promise chains and nothing else.

  • Why does an error thrown inside an awaited async function usually show a fuller trace than one thrown in a setTimeout callback?
    Modern V8 implements async stack traces: when an async function awaits a promise, the engine records enough about the suspended frames to splice them back into a trace produced in the continuation. That mechanism is tied to awaiting promises inside async functions. A plain callback handed to a timer has no such recorded relationship — it is invoked from an empty stack, so there is nothing to splice.
  • A production trace stops after ten frames with no marker. What is the most likely cause?
    `Error.stackTraceLimit`, which defaults to 10 in V8 and truncates silently. Raise it while investigating and put it back afterwards, because capturing more frames costs CPU on every error construction. If raising it does not lengthen the trace, the truncation is an async boundary instead: the missing frames were popped before the failing code ran and no limit change will bring them back.
  • What is the cost of capturing a context error at every scheduling point, and how do you keep it acceptable?
    Constructing an error captures frames, which is not free, and each retained error object holds its string alive. On a high-rate dispatch path that shows up in both CPU and memory. Apply it at the small number of boundaries where async work is handed off — a job dispatcher, a queue consumer — rather than at every call site, or gate it behind a debug flag that a deployment can flip during an investigation.

saying these in an interview costs you the question

  • Says a flag exists that restores all lost frames
  • Thinks await stitching also covers setTimeout callbacks
  • Blames the frame limit for every truncated trace
  • Believes rethrowing the same error adds caller frames
  • Expects try/catch around setTimeout to catch the callback's throw

context