The first time someone asked me, in an interview, what this snippet logs, I got it wrong:
I said A B C because the promise was already resolved, so the callback "should" run immediately. The right answer is A C B. The .then callback never runs synchronously, even on a pre-resolved promise. That single fact is the spine of the microtask queue, and it changes the way I read every async function I write.
The argument I want to make in this piece is that promises are simpler than they seem once you accept one rule: every .then callback, every await continuation, and every queueMicrotask body runs as a microtask, not synchronously. Microtasks drain to empty before the next macrotask, including before any rendering. Get that ordering right and the rest is mechanical.
What a microtask is, in one sentence
A microtask is a function the engine has agreed to run after the current synchronous code finishes, before yielding control back to the event loop. Microtasks live in a queue. The engine drains the queue to empty before doing anything else (no new task, no rendering, no timer callback) every time it returns to the event loop.
The four sources of microtasks worth knowing:
| Source | Produces a microtask when |
|---|---|
Promise.resolve().then(fn) | The promise resolves (or already has) |
await expr | Control returns to the function after expr settles |
queueMicrotask(fn) | Always; this is the explicit API |
MutationObserver callbacks | The observed DOM mutation completes |
setTimeout, setInterval, setImmediate (Node), MessageChannel.postMessage, I/O completions, and event listener firings are all macrotasks (also called tasks). The rule is that the engine processes one macrotask, drains all microtasks, possibly renders, then picks the next macrotask.
Walking the opening snippet
Lines 1-3 run synchronously. Line 2 does not invoke the callback; it schedules it. Once line 3 returns and the script's synchronous region is done, the engine drains the microtask queue and finally logs B. Output is A C B.
The lesson is that "the promise is already resolved" does not mean "run the callback now". It means "schedule the callback as a microtask now". The schedule-but-defer behavior is what makes .then chainable safely; if some .then callbacks ran synchronously and others did not, the order of operations would depend on timing of resolution, and reasoning about chains would be impossible.
How await schedules a microtask, not the function body
This was the second thing I got wrong, for longer than I want to admit. I used to say "an async function runs as a microtask". That is not what happens. An async function runs synchronously up to the first await. At that await, the function pauses and arranges for its continuation (the rest of the body) to run as a microtask once the awaited value settles.
Output: A C B. example runs synchronously up to await, logs A, then suspends. console.log('C') runs. The microtask queue is drained, the suspended continuation resumes, B is logged.
Each await introduces a microtask. A function with three awaits therefore has three queue trips. Most of the time this is invisible. Occasionally it matters: if you await something that is already resolved (a cached value, a synchronous operation wrapped in Promise.resolve for shape), every await still costs one microtask, and tight loops of trivial awaits can produce surprising performance shapes. The fix when it actually matters is to remove the await (return the promise directly, or return the value synchronously and let the caller wrap).
The trick: await runs continuations in microtask order
When two awaits resolve "at the same time", the order is the order in which the awaits were registered. Microtasks are FIFO.
Output: sync, then first, then second. Both first and second ran synchronously up to their await, suspended themselves as microtasks, then the synchronous tail ran, then microtasks drained in order.
This becomes load-bearing once you start interleaving promise chains.
Output: a1 b1 a2 b2, not a1 a2 b1 b2. Each .then only schedules the next microtask after its callback runs. By the time a1 has logged and queued a2, b1 is already in the queue ahead of it. The two chains interleave because microtask order is "registration order", and registration only happens after each prior callback finishes.
Microtasks drain before macrotasks, even when both are ready
Macrotask sources (setTimeout, I/O) cannot insert themselves into a running microtask drain. If the microtask queue contains 10,000 items and they all enqueue more microtasks as they run, the engine will run all 10,000 plus the new ones before considering the next setTimeout callback. This is the behavior people mean when they say "you can starve the event loop with microtasks".
This locks the event loop. The setTimeout callback never gets a chance because the microtask queue keeps regenerating. The same shape with setTimeout(recursive, 0) instead would not lock; the event loop would interleave timer callbacks with rendering and other I/O on every tick.
The diagnostic I keep in my head is that microtasks are for "finish what we started"; macrotasks are for "everything else". If your finish-what-we-started loop never stops, nothing else gets a turn.
Where this matters in real code
Three places I have seen the microtask model bite real applications.
Render after setState in React. React batches state updates and flushes them as microtasks (in the modern concurrent renderer; legacy was different). If you await something in an event handler, the flush has already happened when the continuation resumes, so subsequent setState calls are a separate batch. This is the "two re-renders for one click" issue people occasionally trace. The fix in pre-18 React was unstable_batchedUpdates; in modern React all updates are batched automatically, but the timing model is still microtask-driven.
Tests that pass with await null and fail without it. A test that asserts post-state immediately after a setState will fail because the state has not flushed yet. A common patch is await null or await Promise.resolve() to give the microtask queue one drain cycle. That works, but it is a smell: the better fix is act() from react-dom/test-utils (or the testing-library wrapper), which guarantees both microtask and effect flushes before the assertion runs.
Web servers that call res.send after an unawaited promise. If a route handler kicks off a promise without awaiting it and then sends a response, the response is sent first; the promise's .then runs as a microtask after the request is officially complete. Errors in the unawaited promise become unhandled rejections, which crash the process under modern Node defaults. The unawaited promise lint rule is the cheapest fix; treating every async path as either fully awaited or fully fire-and-forget (with explicit error handling) is the structural one.
What Promise.all, Promise.race, and Promise.any add to the model
The combinators are not separate machinery. They produce a single promise whose resolution depends on the inputs in a defined way, and that resolution drops a microtask on the queue like any other.
Promise.all([p1, p2, p3]) resolves to an array of values when every input resolves, or rejects with the first rejection. It does not cancel pending promises; it just stops caring about their outcomes. If p2 rejects after p1 and p3 are already resolved, the combinator still rejects with p2.
Promise.race([p1, p2]) settles (resolve or reject) with the first input to settle. This is the building block for timeouts: Promise.race([fetch(...), timeout(5000)]).
Promise.any([p1, p2]) resolves with the first input to resolve, ignoring rejections. If all inputs reject, it produces an AggregateError. I use this when querying replicas: any one healthy replica is enough.
Promise.allSettled([p1, p2]) always resolves, with an array of {status, value | reason} objects. The combinator I reach for when I want every result and explicit per-input success/failure metadata.
All four schedule a single continuation microtask once their settlement criterion is met; the inner promises kept their own microtask shape regardless.
A trace I can run in my head
The exam I give myself when I am unsure about the ordering of an async block is to draw it as a four-column ledger: synchronous operation, microtask enqueued, microtask dequeued, output line. Each row is one step. The microtasks-enqueued column lengthens during synchronous execution; the dequeue column drains between synchronous regions. If the output column matches what the code logged, my mental model is right.
Four columns, no other rules. The model fits any promise/await snippet; if the trace produces the right output, the code is correct against the spec, regardless of whether you find it readable.
Read every await as a microtask
The single sentence I want every junior on my team to internalise is that await is a microtask boundary, not a "wait for the value" operation. The wait is real, but the wait is the microtask boundary. Once you read every await as "schedule the rest of this function as a microtask after the value settles", the ordering questions become mechanical: what is in the microtask queue, what is in the macrotask queue, which one drains first.
The promise chain is an abstraction over the microtask queue. Knowing the queue exists, knowing how to enqueue and drain it, knowing that microtasks block macrotasks, is the difference between writing async code that you can reason about and writing async code that mostly works until the day it does not. Trace any async snippet you are unsure about with the four-column ledger above and the engine's behavior is no longer a mystery.
