The event loop beyond setTimeout: microtasks, task queues, and starvation
You know the interview answer. This is the production version: how microtask and task queues actually interleave, where await really yields, and how a queue can starve your UI without a single blocking call.
Every JavaScript developer can recite "single thread, callback queue, non-blocking IO". Almost none can correctly predict the output of eight lines mixing a promise with a timer — and the gap between those two facts is where production bugs live: the spinner that never paints, the "async" handler that still blocks, the test that passes locally and races in CI.
The model that actually predicts behavior needs one refinement over the interview answer: there is not a queue. There are (at least) two kinds, with brutally different scheduling rules.
Tasks and microtasks are not the same queue
A task (or macrotask) is a unit of work the event loop picks up one at a
time: a setTimeout callback, an IO completion, a DOM event handler, an incoming
message. A microtask is what promises use: .then reactions, await
continuations, queueMicrotask callbacks.
The scheduling rule that explains almost everything:
After every task — and, in the browser, before rendering — the engine drains the entire microtask queue, including microtasks enqueued by other microtasks.
One task per turn; all microtasks, to exhaustion, every turn. Tasks are a polite queue; microtasks are a mob that lets its friends cut in line.
The classic, one level deeper
console.log("A");
setTimeout(() => console.log("B"), 0);
Promise.resolve()
.then(() => console.log("C"))
.then(() => console.log("D"));
console.log("E");What does this print?
await is a suspension point, not a delay
Rewriting promise chains to async/await doesn't change the scheduling — it
changes only the syntax. Everything before the first await in an async
function runs synchronously, as part of whoever called it. The await itself
suspends the function and schedules its continuation as a microtask when the
awaited value settles.
Two consequences that regularly surprise experienced reviewers:
- Marking a function
asyncdoes not make it yield.async function f() { heavyLoop() }blocks exactly as long as the plain version — the caller just gets a promise as a receipt for having been blocked. await somePromiseyields even if the promise is already resolved. The continuation still goes through the microtask queue; you always give the queue one turn.
Where does the async function actually pause?
async function work() {
console.log("1");
await Promise.resolve();
console.log("2");
}
work();
console.log("3");What does this print?
Starvation: fast code that freezes everything
Here's where the "drain microtasks to exhaustion" rule turns from trivia into an incident report. If every microtask schedules another microtask, the drain never finishes: the current turn never ends, no task ever runs again, and the browser never reaches its render step. No blocking call, no heavy computation — every individual callback returns in microseconds — and the page is dead.
function processForever() {
queueMicrotask(processForever); // the queue never empties
}The same bug wears production clothing: a retry loop that re-awaits
immediately on failure, a state library flushing subscribers that trigger more
updates, a recursive "process next chunk" that chose the wrong scheduling
primitive. The fix is always the same idea — yield to the task queue so
rendering and IO get a turn.
Processing a huge dataset without freezing the UI
Each variant processes work in chunks and reports progress. One of them starves rendering and never lets the progress bar paint. Which?
The loop has phases, and Node's has more
The browser loop's contract per turn: run one task → drain microtasks → maybe
render. Node.js implements the same task/microtask split but slices tasks into
phases — timers, pending callbacks, poll (IO), check (setImmediate), close
callbacks — with the microtask queue (plus Node's own process.nextTick queue,
which outranks even promises) drained between every callback.
You don't need to memorize libuv's phase diagram. You need three durable facts:
process.nextTickbeats promise microtasks; both beat every timer and IO callback. A recursivenextTickstarves a Node server exactly like ourqueueMicrotaskbomb starves a browser tab.setImmediate(check phase) is Node's honest "next turn" primitive — closer in spirit to what people want fromsetTimeout(fn, 0).- Timer ordering across phases is not guaranteed where you'd hope:
setTimeout(fn, 0)vssetImmediate(fn)at top level can fire in either order, which is a classic source of flaky tests.
Make it print 1, 2, 3, 4 — in that order
Drag the lines into the order that makes this code work.
console.log("3");console.log("1");queueMicrotask(() => console.log("2"));});setTimeout(() => console.log("4"));setTimeout(() => {
The durable model
Everything in this lesson compresses to four sentences. One task per turn. All
microtasks after it, to exhaustion — they can starve the world. await runs
synchronously until it doesn't, then everything after it is a microtask.
Rendering and IO live in the task world, so yielding to them means scheduling
a task, not a promise.
That model predicts the output of every snippet above — and, more usefully, the behavior of your retry logic, your state batching, and your "why is the spinner frozen" bug from last quarter.