Web Platform · JavaScript Runtime

The Microtask Checkpoint and How Promise Jobs Actually Run

The mechanics behind promise callbacks: when the job queue drains, how await lowers to a reaction, and why a thenable resolution costs extra turns.

A promise callback is not an event handler and does not run when the promise settles. It runs later, in a queue that has its own drain rule: the queue empties completely before the browser does anything else, including running the next task. This asymmetry between “settled” and “observed” is the single most important fact about promise timing, and it is defined outside the language proper. ECMAScript defines the job queue and the jobs that go into it; the host — in a browser, the HTML event loop — decides when to run the jobs.

The result is a two-tier scheduler. Tasks, described in the event loop model, are units of work that can be interleaved with rendering and input. Microtasks are units of work that cannot: once the checkpoint starts, everything already queued runs before control returns to the loop.

An empty stack is the trigger

The ECMAScript specification calls the unit a job and the container a job queue. A promise reaction is enqueued as a PromiseReactionJob; queueMicrotask enqueues a callback job; MutationObserver delivery uses the same machinery through the host. Jobs in one queue run in FIFO order.

The host’s rule, from the HTML standard, is that a microtask checkpoint is performed whenever the JavaScript execution context stack becomes empty. Three moments satisfy that condition:

  • a task has finished running its callback and no frames remain;
  • a host-invoked callback returns while no other JavaScript is on the stack, as when a timer or observer callback finishes; the listeners of one event dispatch share a single stack and are not separated by checkpoints;
  • during a nested event loop spin, such as the one alert uses or the one a synchronous XMLHttpRequest performs while waiting.

The checkpoint drains until the queue is empty, and jobs enqueued during the drain are appended and run in the same checkpoint. It is not “run the jobs that were queued when the checkpoint began”. That recursive draining is what lets a chain of then callbacks complete in one pass and what makes an unbounded chain able to block the page forever.

One consequence that surprises people: code after await in an async function runs before a subsequently scheduled setTimeout, even a zero-delay one, because the continuation is a job and the timer callback is a task. It also runs before a requestAnimationFrame callback for the same frame if the await was reached during the current task.

Lowering await to a reaction

An async function is a function that returns a promise. Its body runs synchronously until the first await; at that point the engine evaluates the operand, wraps it in a promise, attaches a reaction whose handler resumes the suspended execution, and returns control to the caller. The rest of the body becomes the continuation inside that reaction.

async function f() { return x } and function f() { return Promise.resolve(x) } differ in one observable way: if x is an already-resolved promise, the async version adopts it asynchronously, adding a turn, while Promise.resolve returns the same object without an extra hop. Every async function also wraps thrown exceptions into a rejected promise, which is why calling one never throws synchronously — the error surfaces on the returned promise.

async function f() {
  console.log('a');
  await null;          // continuation becomes a promise job now
  console.log('b');
}
f();
Promise.resolve().then(() => console.log('c'));
console.log('d');
// a d b c — the await resumption was queued before the explicit then.

Two awaits in sequence cost two turns, not one. Each suspension ends the current job and schedules a new one, so a loop of n awaits interleaved with ordinary code performs n separate job transitions. The engine does not batch them; the JavaScript-level mental model of “resume where it left off” is real, but the resumption is a fresh queue entry and the stack is empty in between. That is precisely why awaiting an already-settled value does not yield to rendering or input: the continuation is a microtask, and the checkpoint drains it before the loop can produce a frame or pick up a click. Only awaiting something that settles in a later task — a timer, a network response — hands control back to the loop, which is why chunking a long computation across awaits helps only when each await sits on a real asynchronous boundary.

Thenables pay an extra turn

await accepts any value. A plain value is wrapped in a fulfilled promise with no further scheduling. A native promise is used directly. A thenable — an object with a callable then — cannot be inspected synchronously for its state, so the specification defers: it enqueues a NewPromiseResolveThenableJob that calls the thenable’s then method, passing resolving functions. The promise returned by an async function that awaits a thenable therefore takes at least one additional turn to settle compared with one awaiting a native promise.

The same rule governs resolution elsewhere. resolve(thenableObject) inside a promise executor does not call then immediately; the adoption is scheduled, which is why a badly behaved thenable that calls both resolve and reject cannot corrupt the promise, and why a thenable that never calls either leaves the outer promise pending forever. The idempotence of the resolving functions — first call wins, later calls are ignored — is what makes that safe.

Library code that returns hand-rolled thenables for lazy evaluation inherits this extra latency and this failure mode. Returning a native promise, even one created by new Promise, costs one turn less and cooperates with await’s fast path.

Rejections with no owner

A rejection does not throw. It records a reason on the promise and enqueues reaction jobs only if a reaction was attached at that moment. If nothing is attached, the promise sits rejected, and the host reports it: browsers fire unhandledrejection on the global object, Node emits unhandledRejection on the process. The check happens at a defined point relative to the checkpoint — not immediately when the rejection occurs — and attaching a handler later fires rejectionhandled instead, if the host supports it.

This has a practical consequence for sequencing. In p.then(f); p.catch(g), the rejection is delivered to g, so p itself counts as handled even though f never runs. The promise that then returned is a separate one, rejected with the same reason, and if nothing handles it the report names that derived promise rather than p. The handler does not have to be the last link in the chain; it has to exist before the host decides which promise is unhandled. The inverse also holds: p.then(f) where f throws produces a rejected promise that nobody owns, and the diagnostic points at the derived promise, not at p.

Awaiting a rejected promise propagates the rejection into the awaiting function, where it can be caught by try/catch around the await. If nothing catches it, the async function’s own returned promise is rejected, and the ownership question recurses upward until it reaches a caller that handled it or the global handler.

Limits of the drain rule

The checkpoint guarantees ordering, not progress. A microtask that enqueues another microtask keeps the queue non-empty, so the loop never reaches rendering or the next task; the page stops responding while the stack is technically shallow. Nothing in the standard bounds the drain, and no browser will interrupt it. Recursive promise scheduling is never a substitute for chunking work across tasks.

Ordering across threads is also absent. A worker has its own job queue and its own checkpoints, so a promise resolved inside a worker and a promise resolved on the main thread have no defined relative order except as constrained by message delivery. And within one thread, a job scheduled before an in-progress task ends runs before any rendering step for that frame, which means layout reads inside promise callbacks still observe the previous frame’s boxes unless something forced a synchronous update.

Node’s model adds a wrinkle: process.nextTick callbacks and promise jobs are drained between microtask checkpoints of their own, with nextTick taking priority over promise reactions. Code that moves between browser and server and depends on the interleaving of those two queues has found a difference that neither specification promises away.

References

  1. ECMAScript Language Specification — Jobs and Job Queues — Ecma International
  2. HTML Living Standard — Performing a microtask checkpoint — WHATWG
  3. Promises/A+ Specification — Promises/A+ organisation