"Every async thing in Node lives in one of two queues. Knowing which lets you predict order, debug starvation, and stop being surprised when await 'doesn't yield enough.'"
Two Queues, One Loop
The Node event loop maintains a strict separation between two kinds of scheduled work:
- Macrotasks (a.k.a. tasks) — the six phases of the event loop. Each phase processes its own queue:
setTimeout/setIntervalcallbacks in Timers, I/O callbacks in Poll,setImmediatecallbacks in Check, close handlers in Close. - Microtasks — a separate queue drained between each macrotask AND between each phase.
queueMicrotask(fn),Promise.thenhandlers,awaitcontinuations all go here. Plus a sub-queue forprocess.nextTick, which drains BEFORE the Promise microtask queue.
The Draining Rule
Promise.resolve().then(fn) always runs before any I/O or timer that you scheduled in the same synchronous block — the microqueue gets drained first.The trap: if your microtasks recursively schedule more microtasks, the loop never progresses past the microqueue. Your server stops responding. This is microtask starvation; it's the async-await version of nextTick starvation from Lesson 3.
The Actual Order
For a single synchronous code block in Node, the schedule is:
- The synchronous code itself.
- All
process.nextTickcallbacks (FIFO). - All Promise microtasks —
queueMicrotask,.then,awaitresumes (FIFO). - One macrotask from the next event loop phase (Timers if there's an expired timer, otherwise Poll/Check/etc.).
- GOTO 2 (microqueue drains again before next macrotask).
Step 5 is the killer detail. Most developers think "phases, phases, phases, with microtasks somewhere in between." Actually it's "phase, drain microtasks, phase, drain microtasks, ..." — microtasks run vastly more often than phases.
Why await Feels Synchronous
Because the continuation of an await is a microtask. After the line const x = await something(), the rest of the function gets scheduled into the microqueue. The very next thing the runtime does (after the current sync block and any earlier microtasks) is resume your function. There's no "wait for the next tick" — there's "wait until the microqueue is drained," which is usually microseconds.
Critically: a sync block followed by await followed by more sync work doesn't yield to I/O. The post-await sync work runs in the microqueue, and the microqueue blocks the next phase. If you have a CPU-heavy block after an await, you're still blocking the event loop, just from a microtask instead of from the original sync block.
setImmediate vs queueMicrotask — When To Use Which
If you want to give I/O a chance to run, use setImmediate(fn) — it goes in the Check phase, after a Poll iteration, meaning new connections and I/O callbacks fire before fn. If you want to defer work just past the current sync block but BEFORE any I/O, use queueMicrotask(fn) — same priority as a .then.
If you're writing a loop that needs to process big chunks of work without blocking forever, sprinkle setImmediate between chunks — that's how you cooperatively yield to the rest of the system.
Pippa's Confession
setImmediate(...) in the middle. The microqueue can become a CPU sink even when each handler is "fast." Now when I write a long .then chain or a tight for await loop, I ask: "does this need to yield to I/O somewhere?" Usually yes.