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.
A promise is a small object with a list
"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.
- 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).
- PromiseReaction: fulfil handler, reject handler, the derived promise (the one
thenreturned), and for await, the generator to resume instead of a handler. Appended bythen; one allocation. - PromiseReactionJob: created at settle time, one per reaction, holding the handler, the argument, and the derived promise. Enqueued on the microtask queue.
- PromiseResolveThenableJob: created when a promise is resolved with another promise or thenable; calls its
thenwith the outer promise's resolve and reject. - The resolve and reject functions passed to the executor are closures with an "already resolved" flag shared between them, so the first call wins.
new Promise(executor): allocate; run the executor synchronously with the resolve/reject closures. Throwing inside the executor rejects.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)isthen(undefined, r);finally(f)is a then with wrappers that pass through the value.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.reject(e): set state and result; enqueue reactions; if there were none, notify the host's rejection tracker.- Static combinators (
all,allSettled,race,any): iterate the input, callthenon each (viaPromise.resolvefirst), with a shared counter in closures.Promise.allof N promises is N reactions plus N derived promises plus the result.
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.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.
// 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- PromiseResolve(v): if v is a native promise whose constructor is
Promise(checked with a protector so a patchedPromise.prototype.thenis noticed), use v directly. Otherwise create a new promise resolved with v (which, for a thenable, enqueues a resolve-thenable job). - 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).
- SuspendGenerator: save the frame; return to the caller of the async function (on the first await) or to the microtask runner (on later awaits).
- On settle: the reaction job runs
AsyncFunctionAwaitResolveClosure: ResumeGenerator with the value (or throw completion). The body continues until the next await or the end. - 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.
- 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).
- 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.
- Sequential awaits serialise;
Promise.allover the started promises runs them concurrently. The classic mistake isfor (const u of urls) await fetch(u)whereawait Promise.all(urls.map(fetch))was meant. return awaitversusreturn: inside a try/catch,return await plets the catch see p's rejection; outside, it costs one hop for a better stack trace. Not a mistake either way; a choice.- Top-level await in modules: the module's evaluation becomes async; importers wait (part 11).
--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.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.
- 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).
- 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).
- 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.
thenon an already-settled promise still waits for the drain. It is never synchronous.- 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.
awaitinside a loop interleaves with other microtasks between iterations. A UI framework's state update scheduled as a microtask can run between your loop iterations.- 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.
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.- 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
dispatchEventfrom script, there is JavaScript on the stack, so no draining until the dispatch returns. queueMicrotaskand promise reactions share one queue; there is no priority between them.
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.
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.
- 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.
- 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).
- Limits: the chain breaks at a
thencallback (not an async function), atPromise.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. - 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).
- Creating an Error captures a stack trace: walking frames, formatting lazily (the
stackstring is built on first access).Error.stackTraceLimit(default 10) bounds it. A thousand errors a second is a thousand captures. - 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.
- Rejected promises with Error reasons pay the capture at creation of the Error, not at rejection.
Error.captureStackTrace(obj, fn)captures with frames abovefnhidden; used by libraries to make traces start at the user's code.- Source maps are applied by tools (DevTools, Node's
--enable-source-maps), not by the engine; the engine reports generated positions.
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.Unhandled rejections, cancellation, and the patterns that break
// 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.- When: a promise is rejected and has no reactions (has_handler false) → the engine calls the host's
HostPromiseRejectionTrackerwith "reject". If a handler is attached later → "handle". The host decides what to do and when. - 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 firesrejectionHandled. - Browsers: the
unhandledrejectionevent on the window, logged to the console if notpreventDefaulted;rejectionhandledfor late handlers. - 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.
- Fire-and-forget async callbacks: an
asyncevent handler or lifecycle hook whose returned promise nobody awaits. Wrap the body in try/catch, or.catchat the call. - 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(orallSettled) attaches handlers to all of them immediately. - 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.
- Rejecting inside a
thenwhose derived promise is dropped:p.then(f)with no catch and the result unused.
- Promises cannot be cancelled; the language has no mechanism. The pattern is a signal:
AbortControllerandAbortSignal, whichfetch, timers (setTimeoutin Node with{signal}), streams and event listeners accept. Your own async functions accept a signal, checksignal.abortedor listen toabort, and reject with the signal's reason. AbortSignal.timeout(ms),AbortSignal.any([...]): composable timeouts and combined signals.- A cancelled await still resumes (with a rejection); the frame is not thrown away. Cleanup belongs in
finally. - Leaks: a promise that never settles holds its reactions and their closures forever. Every long wait should have a timeout or a signal.
Reading async: tools
| Tool | Shows | Use it for |
|---|---|---|
%DebugPrint(promise) | Status, result, reactions count, has_handler | Is it settled; is anyone listening |
| DevTools Sources → pause → Call Stack with "async" frames; "Async" checkbox | The reconstructed await chain; creation stacks for timers and listeners | How execution got here across awaits |
| DevTools Performance → Main lane: "Run Microtasks" blocks after tasks | How long each drain took; which jobs ran | A drain that starves rendering; a microtask storm |
node --trace-event-categories v8.execute or --cpu-prof, look for (program) and microtask runner frames | Time spent in promise machinery | Async overhead in a hot path |
process.on('unhandledRejection'), window.onunhandledrejection | Every unhandled rejection with its promise and reason | Finding fire-and-forget bugs; always install one in production with reporting |
node --unhandled-rejections=strict|warn|throw | Policy | Strict in CI to catch late-handled ones too |
async_hooks / AsyncLocalStorage (Node) | Async context propagation; the lifecycle of every async resource including promises | Request-scoped context; also a measurable overhead when enabled |
--trace-promise-hooks / v8.promiseHooks (Node) | init, before, after, settled for every promise | Counting promises per operation; finding chains that never settle |
| A six-line ordering test (chapter 3's figure) | Your own understanding | Before every "why did this run first" investigation |
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.