skip to content

Some event-loop runtimes keep two queues: a task queue (macrotasks) and a microtask/continuation queue drained after each task. What is the ordering rule, why does the second queue exist, and what hazard does it create?

level: middleimportance: should knowfreq 46%

answer

  1. one macrotask, then drain all microtasks
  2. microtasks added during the drain run in the same drain
  3. purpose: settled continuations finish before new input
  4. zero-delay timer runs after all continuations
  5. self-re-arming microtask = total I/O starvation

basics

~20 s

Rule: run one macrotask to completion, then drain the entire microtask queue — including microtasks that microtasks add — before the next macrotask. Microtasks exist so continuations of already-settled operations finish before new external events. Hazard: an endlessly self-enqueuing microtask starves all I/O and timers.

solid answer

~60 s

**Ordering rule.** The loop takes one macrotask (an I/O event, a timer callback, a posted task), runs it to completion, then drains the microtask queue *entirely* — and any microtask enqueued during that drain is also run in the same drain — before touching the next macrotask. **Why the second queue.** Microtasks are continuations of things that already have their answer: a resolved promise's handler, a completed continuation. Running them immediately, before any new external event, gives two properties: nobody observes a half-settled chain, and a logical operation split across several continuations completes as one unit relative to external events. Without it, an unrelated timer could interleave inside your callback chain and see intermediate state. **Hazard.** The drain has no bound. A microtask that schedules another microtask loops forever inside the drain, and no I/O, timer, or rendering ever happens again — the loop is alive but starved. The system looks hung with no CPU-free thread to blame. Also the ordering surprises people: a zero-delay timer runs *after* every pending continuation, not before.

code

text · 11 lines
text
current macrotask:
  log("A")                     -> prints A immediately
  scheduleTimer(0, ...)        -> macrotask queue
  onResolved(value, ...)       -> microtask queue
  log("B")                     -> prints B immediately
end of macrotask
  drain microtasks             -> resolved callback runs (C)
next macrotask
  timer callback runs          -> (D)

output: A B C D

go deeper

for a junior

State the rule — one task, then the whole microtask queue, then the next task — and that a zero-delay timer therefore runs after pending continuations.

for a middle

Explain why the second queue exists: settled continuations complete before new external events, and callback timing does not depend on whether the result was immediate.

for a senior

Discuss microtask starvation as a production failure mode, its symptoms (loop alive, one core pegged, health checks failing), and the yield-by-macrotask fix for self-re-arming work.

for a principal

Frame it as a scheduling-policy tradeoff between chain atomicity and fairness, and note that phase-based draining or per-iteration budgets are how runtimes bound the resulting starvation risk.

## Two queues, two roles - **Macrotask queue (task queue)**: external work — an I/O readiness callback, a timer that expired, a task posted from another thread. Represents new input into the system. - **Microtask queue (continuation/job queue)**: the rest of a computation whose result is already available — a callback attached to an already-settled promise, a resumed continuation. ## The rule, precisely 1. Pop one macrotask, run it to completion. 2. Drain the microtask queue until it is empty. Microtasks enqueued *during* the drain go to the back of the same drain and run in this pass. 3. Do any per-iteration housekeeping (rendering, timer collection, readiness polling). 4. Repeat. Microtasks therefore always run *before* the next macrotask, and always in FIFO order among themselves. ## Why the distinction exists Consider a chain: parse a response, transform it, update a shared index, then notify. If each step is a continuation and the runtime treated all callbacks as macrotasks, a timer or a new socket event could be dispatched between steps and observe the index half-updated. By running continuations in a drain that completes before any new external event, the runtime gives the chain a coarser atomicity with respect to the outside world. A second reason is ordering determinism. Because settled-promise callbacks are always deferred to the microtask queue rather than sometimes invoked synchronously, the ordering is the same whether an operation completed instantly or after a delay. Without that rule you get "sometimes synchronous, sometimes not" callbacks, which is a classic source of nondeterministic bugs. ## The starvation hazard The drain runs until empty, and each microtask may enqueue more: ``` function spin(): scheduleMicrotask(spin) # re-arms itself scheduleMicrotask(spin) ``` The drain never terminates. The loop never reaches step 3: no I/O, no timers, no rendering, no health checks. Symptoms are nasty because the process is not deadlocked and not idle — one core is pegged, the loop's own metrics stop updating, and health probes time out. The realistic version is not an infinite loop but a hot chain: a recursive continuation that walks a large data set one item per continuation, or a producer that resolves faster than the consumer drains. The rule of thumb: **recursion or iteration that re-arms itself should use a macrotask, not a microtask**, so each turn gives the loop a chance to service I/O and timers. ## Ordering surprises Given, inside one macrotask: schedule a zero-delay timer, then attach a continuation to an already-resolved value, then do some synchronous logging. Order of output: the synchronous log first (still inside the current macrotask), then the continuation (microtask drain), then the timer callback (a subsequent macrotask). Candidates routinely predict timer-before-continuation because "zero delay means immediately". Zero delay only means *eligible* immediately; the continuation queue has strict precedence. ## Reasoning consequences Because the microtask drain runs to exhaustion before the next external event, the atomic unit with respect to external input is "macrotask plus its whole microtask drain". That is bigger than one handler — useful when you want a chain to appear indivisible, dangerous when you assume the loop stays responsive during it. For fairness you often want the opposite of atomicity: to give the loop a breath. Explicitly re-scheduling as a macrotask is the standard yield. Some runtimes also bound how many macrotasks are drained per iteration, or drain queues by phase, precisely to keep I/O from being starved by a hot task queue. ## What to say in an interview State the rule, name the purpose (continuations of settled work must finish before new external events, and callback timing must not depend on whether the result arrived instantly), then name the failure: unbounded drains starve I/O and timers, so self-re-arming work belongs on the macrotask queue.

  • Inside one turn you schedule a zero-delay timer and also attach a callback to an already-resolved value. Which runs first, and why?
    The callback on the resolved value runs first. It goes on the microtask queue, which is drained completely before the loop moves on to the next macrotask, and timer callbacks are macrotasks. A zero delay means the timer is eligible immediately, not that it jumps the continuation queue.
  • You need to process a million records without freezing the loop. Why is scheduling each step as a microtask the wrong choice?
    Because the microtask drain runs until empty and re-armed microtasks join the same drain, so all million steps run in one uninterrupted pass and no I/O, timer, or health check is serviced. Scheduling each chunk as a macrotask lets the loop poll for readiness between chunks, keeping the service responsive at the cost of slightly longer total processing.

The macrotask queue is the waiting room; the microtask queue is finishing the paperwork for the patient already in the room. Finishing paperwork first is right — unless the paperwork keeps generating more paperwork, and the waiting room is never called again.

saying these in an interview costs you the question

  • Believing a zero-delay timer runs before pending continuations
  • Thinking the microtask drain is bounded to the microtasks present when it started
  • Assuming microtasks run on a separate thread or in parallel
  • Using self-re-arming microtasks for long iteration, then blaming CPU or garbage collection for the stall
  • Claiming the two queues exist only for performance rather than for ordering guarantees

context