Part 10 · 6 chapters · ~45 min

Async Internals

A promise is a state, a result and a list of reactions; resolve moves the list to the microtask queue; await is a generator suspend with one of those reactions attached. This part is the promise object and its operations, await desugared step by step, the three ordering rules and their consequences, async stack traces and the cost of errors, unhandled rejections, cancellation and the patterns that break, and the tools.

69

A promise is a small object with a list

the question

"When I call resolve(), does the then callback run? If not, when?"

No. resolve() changes the promise's state and moves its reactions onto the microtask queue as jobs. The callbacks run when the engine drains that queue: after the current task's synchronous code finishes, before the host gets to run anything else. A promise is three fields and a list; everything about its timing follows from when the list is moved to the queue and when the queue is drained.

the object
  1. JSPromise: status (pending, fulfilled, rejected), result (the value or reason), reactions (a linked list while pending), and flags: has_handler (for unhandled rejection tracking), is_silent, async task id (for async stack traces).
  2. PromiseReaction: fulfil handler, reject handler, the derived promise (the one then returned), and for await, the generator to resume instead of a handler. Appended by then; one allocation.
  3. PromiseReactionJob: created at settle time, one per reaction, holding the handler, the argument, and the derived promise. Enqueued on the microtask queue.
  4. PromiseResolveThenableJob: created when a promise is resolved with another promise or thenable; calls its then with the outer promise's resolve and reject.
  5. The resolve and reject functions passed to the executor are closures with an "already resolved" flag shared between them, so the first call wins.
the operations
  1. new Promise(executor): allocate; run the executor synchronously with the resolve/reject closures. Throwing inside the executor rejects.
  2. then(f, r): allocate the derived promise (via the species constructor, with a fast path when the protector says Promise is unmodified) and a reaction; if pending, append; if already settled, enqueue a job right away. Return the derived promise. catch(r) is then(undefined, r); finally(f) is a then with wrappers that pass through the value.
  3. resolve(v): if v is the promise itself, reject with a TypeError; if v is a promise or thenable, enqueue a resolve-thenable job; else fulfil: set state and result, enqueue a job per reaction, clear the list.
  4. reject(e): set state and result; enqueue reactions; if there were none, notify the host's rejection tracker.
  5. Static combinators (all, allSettled, race, any): iterate the input, call then on each (via Promise.resolve first), with a shared counter in closures. Promise.all of N promises is N reactions plus N derived promises plus the result.
run it
node --allow-natives-syntax -e "const p=new Promise(r=>setTimeout(r,10,42)); p.then(v=>console.log(v)); %DebugPrint(p)": pending, with one reaction and has_handler true. After the timeout, the same print would show fulfilled with result 42 and an empty reactions list.
A PROMISE, SETTLED
the object, its reactions, and the jobs they become
swipe the figure sideways, or tap expand for full screen
1/6
new Promise
const p = new Promise(executor). The executor runs synchronously, right now, with resolve and reject functions bound to p. p is pending with an empty reactions list. Most of the time the executor starts I/O and returns.
70

await, desugared

An async function is a generator (part 9) driven by promise reactions (chapter 1). await v is: make v a promise if it is not one, attach a reaction that resumes this generator, and suspend. The body before the first await runs synchronously in the caller; everything after an await runs in a microtask.

code
// what async/await is, in terms of parts 9 and 10
async function f() { const x = await g(); return x + 1 }
// ≈
function f() {
  const P = new Promise(); const gen = (function* () { const x = yield g(); return x + 1 })()
  function step(next) {
    const r = next()                                       // resume the frame
    if (r.done) return resolveP(r.value)                   // body finished: settle the implicit promise
    PromiseResolve(r.value).then(v => step(() => gen.next(v)), e => step(() => gen.throw(e)))  // await: one reaction, one hop
  }
  step(() => gen.next()); return P
}
// the real implementation is a builtin (AsyncFunctionAwait) with the same shape and fewer allocations

// the three await costs to know
await promise        // 1 hop (native promise, protector intact)
await thenable       // 2 hops (an extra job calls thenable.then)
return await p       // 1 extra hop vs return p, but: the frame stays for stack traces, and try/catch in f sees p's rejection
the exact steps of await v
  1. PromiseResolve(v): if v is a native promise whose constructor is Promise (checked with a protector so a patched Promise.prototype.then is noticed), use v directly. Otherwise create a new promise resolved with v (which, for a thenable, enqueues a resolve-thenable job).
  2. PerformPromiseThen(promise, onFulfilled = resume, onRejected = resumeWithThrow): append a reaction that holds the generator object, with no derived promise (await does not need one; a 2018 optimisation).
  3. SuspendGenerator: save the frame; return to the caller of the async function (on the first await) or to the microtask runner (on later awaits).
  4. On settle: the reaction job runs AsyncFunctionAwaitResolveClosure: ResumeGenerator with the value (or throw completion). The body continues until the next await or the end.
  5. At the end: the implicit promise is resolved with the return value (a return of a promise adopts it, costing a resolve-thenable hop) or rejected with the thrown error.
what follows
  1. Each await is at least one microtask hop, even when the value is already available. Awaiting in a loop over a thousand ready values is a thousand drains' worth of resumptions (all within one task, but each a job).
  2. Sync-before-first-await: an async function's prefix runs immediately. Two async calls in a row interleave at their first awaits, not at their starts.
  3. Sequential awaits serialise; Promise.all over the started promises runs them concurrently. The classic mistake is for (const u of urls) await fetch(u) where await Promise.all(urls.map(fetch)) was meant.
  4. return await versus return: inside a try/catch, return await p lets the catch see p's rejection; outside, it costs one hop for a better stack trace. Not a mistake either way; a choice.
  5. Top-level await in modules: the module's evaluation becomes async; importers wait (part 11).
run it
--print-bytecode on an async function: Await (or SuspendGenerator with an await flag), ResumeGenerator, and the AsyncFunctionAwaitCaught/Uncaught runtime calls that distinguish an await inside try from one outside. The caught/uncaught split exists so the engine knows whether a rejection will have a handler, for the tracker in chapter 5.
AWAIT, DESUGARED
a generator suspend plus a promise reaction
swipe the figure sideways, or tap expand for full screen
1/6
f() called
Calling f(): allocates the implicit promise P (the return value) and a generator-like object holding f's frame. The body starts running synchronously, right now, in the caller's task, until the first await.
71

Microtask ordering, exactly

The ordering rules are short and absolute, and they are the engine's, so they hold in every host. Everything surprising about promise timing is one of these three rules applied carefully.

the rules
  1. Synchronous code runs to completion first. No microtask runs while any JavaScript frame is on the stack (except at explicit host checkpoints, like between event listeners dispatched by the browser).
  2. Microtasks run in enqueue order, and the drain continues until the queue is empty, including jobs enqueued by jobs. A microtask that keeps enqueueing microtasks starves the host forever (no rendering, no I/O).
  3. Then the host runs the next task. Which task comes next is the host's business: timers, I/O, events, messages, in host-defined order with host-defined priorities.
the consequences people get wrong
  1. then on an already-settled promise still waits for the drain. It is never synchronous.
  2. Two independent promise chains interleave job by job: chain A's first reaction, chain B's first, A's second, B's second. The queue is shared.
  3. await inside a loop interleaves with other microtasks between iterations. A UI framework's state update scheduled as a microtask can run between your loop iterations.
  4. A rejection that reaches the end of the drain with no handler is reported (chapter 5); a handler attached in a later task is too late to prevent the report.
  5. process.nextTick (Node) is a separate host queue drained before the engine's microtasks, each time Node returns from the engine. It always runs before any promise reaction enqueued in the same task.
  6. Event listeners (browser): for a user-dispatched event, the microtask queue is drained between listeners (each listener call is a host callback with a checkpoint). For a synchronous dispatchEvent from script, there is JavaScript on the stack, so no draining until the dispatch returns.
  7. queueMicrotask and promise reactions share one queue; there is no priority between them.
worked numbers
the sequence from the figure, with the rule that explains each line:

  start     ← sync prefix of the async function (rule 1)
  end       ← sync (rule 1)
  A         ← first enqueued microtask (rule 2)
  B         ← second (rule 2)
  after     ← the await's resume job, third (rule 2)
  timeout   ← next task (rule 3)

if you can derive that list without running it, you understand async JavaScript.
ORDERING, EXACTLY
six lines of code and the sequence they produce
swipe the figure sideways, or tap expand for full screen
1/6
timer, then(A)
The task begins. setTimeout(cb, 0): the host registers a timer; nothing is enqueued yet. Promise.resolve().then(A): the promise is already fulfilled, so a reaction job for A is enqueued immediately. Microtask queue: [A].
72

Async stack traces and the cost of keeping them

A stack trace at a throw inside an async function would naturally show only the microtask runner that resumed it. V8 reconstructs the logical chain of awaits ("async stack traces") so the trace reads as if the calls were synchronous. It does so cheaply by walking promise reactions, with no extra bookkeeping at await time.

how it works
  1. At capture time (an Error is created), after the real stack frames, the engine looks at the current microtask: if it is resuming an async function, it finds that function's implicit promise, looks at who is awaiting it (its reactions pointing to other async functions' generators), and appends "async functionName" frames by following that chain.
  2. Zero cost at await: no frames are copied when suspending; the information is reconstructed from the promise graph on demand. This was the design that let Chrome enable async stack traces by default (2019).
  3. Limits: the chain breaks at a then callback (not an async function), at Promise.all (a combinator, though V8 handles all/race/any specially), at a timer or I/O boundary (there is no promise to follow), and when the awaiting async function has already finished.
  4. DevTools goes further: with "async stack traces" in the debugger, DevTools records the creation stack of timers and event listeners too (a real cost, so only while DevTools is open).
the costs around errors
  1. Creating an Error captures a stack trace: walking frames, formatting lazily (the stack string is built on first access). Error.stackTraceLimit (default 10) bounds it. A thousand errors a second is a thousand captures.
  2. Throwing unwinds: walks the handler table of each frame; deoptimised frames need materialisation. Expensive relative to a return; fine at error rates, not as control flow.
  3. Rejected promises with Error reasons pay the capture at creation of the Error, not at rejection.
  4. Error.captureStackTrace(obj, fn) captures with frames above fn hidden; used by libraries to make traces start at the user's code.
  5. Source maps are applied by tools (DevTools, Node's --enable-source-maps), not by the engine; the engine reports generated positions.
run it
node -e "async function a(){ await null; throw new Error('x') } async function b(){ await a() } b().catch(e=>console.log(e.stack))". The trace shows at a, then at async b: the second frame was reconstructed from b's await on a's promise. Replace await a() with return a().then(x => x) and the async frame disappears.
73

Unhandled rejections, cancellation, and the patterns that break

code
// unhandled rejections: a promise rejected with no handler at the end of the microtask drain
const p = Promise.reject(new Error('x'))   // has_handler: false → tracked
// … the drain ends → host callback: HostPromiseRejectionTracker(p, 'reject')
// Node ≥ 15: default mode 'throw': an uncaughtException → process exit (unless 'unhandledRejection' listener)
// browsers: the 'unhandledrejection' event on window; console error if not prevented

p.catch(() => {})   // if this happens LATER (after the drain) → 'rejectionhandled' event: the earlier report was a false alarm
// so: attach handlers in the same task that creates the promise, or synchronously in the chain

// the shape that bites: fire-and-forget async in a sync context
button.onclick = async () => { await save() }   // a rejection here is unhandled: nobody awaits the handler's promise
button.onclick = () => { save().catch(report) }  // explicit

// Promise.allSettled vs Promise.all: all rejects fast on the first rejection and the OTHER promises' later rejections are handled
// (Promise.all attaches handlers to every input), so they do not become unhandled. a hand-rolled loop of awaits does not do this.
the tracker
  1. When: a promise is rejected and has no reactions (has_handler false) → the engine calls the host's HostPromiseRejectionTracker with "reject". If a handler is attached later → "handle". The host decides what to do and when.
  2. Node: collects rejections per tick; at the end of the microtask drain, those still unhandled fire unhandledRejection; the default mode since v15 (--unhandled-rejections=throw) turns them into an uncaught exception, which exits the process unless handled. Later handling fires rejectionHandled.
  3. Browsers: the unhandledrejection event on the window, logged to the console if not preventDefaulted; rejectionhandled for late handlers.
  4. The window: a handler attached synchronously, or in the same microtask drain, is in time. One attached in a later task is late: the report has fired.
patterns that produce them
  1. Fire-and-forget async callbacks: an async event handler or lifecycle hook whose returned promise nobody awaits. Wrap the body in try/catch, or .catch at the call.
  2. Starting several promises and awaiting them one at a time: if the second rejects while you are still awaiting the first, it is unhandled during that gap. Promise.all (or allSettled) attaches handlers to all of them immediately.
  3. A promise stored for later and never awaited (a cache of in-flight requests): attach a no-op catch to the stored promise and rethrow when consumed.
  4. Rejecting inside a then whose derived promise is dropped: p.then(f) with no catch and the result unused.
cancellation
  1. Promises cannot be cancelled; the language has no mechanism. The pattern is a signal: AbortController and AbortSignal, which fetch, timers (setTimeout in Node with {signal}), streams and event listeners accept. Your own async functions accept a signal, check signal.aborted or listen to abort, and reject with the signal's reason.
  2. AbortSignal.timeout(ms), AbortSignal.any([...]): composable timeouts and combined signals.
  3. A cancelled await still resumes (with a rejection); the frame is not thrown away. Cleanup belongs in finally.
  4. Leaks: a promise that never settles holds its reactions and their closures forever. Every long wait should have a timeout or a signal.
74

Reading async: tools

ToolShowsUse it for
%DebugPrint(promise)Status, result, reactions count, has_handlerIs it settled; is anyone listening
DevTools Sources → pause → Call Stack with "async" frames; "Async" checkboxThe reconstructed await chain; creation stacks for timers and listenersHow execution got here across awaits
DevTools Performance → Main lane: "Run Microtasks" blocks after tasksHow long each drain took; which jobs ranA drain that starves rendering; a microtask storm
node --trace-event-categories v8.execute or --cpu-prof, look for (program) and microtask runner framesTime spent in promise machineryAsync overhead in a hot path
process.on('unhandledRejection'), window.onunhandledrejectionEvery unhandled rejection with its promise and reasonFinding fire-and-forget bugs; always install one in production with reporting
node --unhandled-rejections=strict|warn|throwPolicyStrict in CI to catch late-handled ones too
async_hooks / AsyncLocalStorage (Node)Async context propagation; the lifecycle of every async resource including promisesRequest-scoped context; also a measurable overhead when enabled
--trace-promise-hooks / v8.promiseHooks (Node)init, before, after, settled for every promiseCounting promises per operation; finding chains that never settle
A six-line ordering test (chapter 3's figure)Your own understandingBefore every "why did this run first" investigation
the pointer
Part 11 is modules: how import is resolved, linked and evaluated, where top-level await fits (an async module evaluation is this part's machinery applied to a module graph), and how Node bridges ESM and CommonJS. The module graph is evaluated in a specific order for the same reason the microtask queue is: the engine owns it, so it is the same everywhere.