const foo

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.

6 min read4 exercisesadvanced

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.

Predict the output

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 async does 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 somePromise yields even if the promise is already resolved. The continuation still goes through the microtask queue; you always give the queue one turn.
Predict the output

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.

each iteration is fast; the page still freezes
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.

Spot the difference

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.nextTick beats promise microtasks; both beat every timer and IO callback. A recursive nextTick starves a Node server exactly like our queueMicrotask bomb starves a browser tab.
  • setImmediate (check phase) is Node's honest "next turn" primitive — closer in spirit to what people want from setTimeout(fn, 0).
  • Timer ordering across phases is not guaranteed where you'd hope: setTimeout(fn, 0) vs setImmediate(fn) at top level can fire in either order, which is a classic source of flaky tests.
Reorder the lines

Make it print 1, 2, 3, 4 — in that order

Drag the lines into the order that makes this code work.

  1.   console.log("3");
  2. console.log("1");
  3. queueMicrotask(() => console.log("2"));
  4. });
  5.   setTimeout(() => console.log("4"));
  6. 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.