Node.js event loop: phases and the microtask queue
- Last updated
- Prerequisites:
- JavaScript async basics
- Understanding of callbacks and promises
- nodejs
- event-loop
- libuv
- microtasks
- async
- javascript
Read at your depth
The practical view
Node runs a single thread executing the event loop: at each iteration it processes phases in order — timers (setTimeout/setInterval callbacks ready), pending callbacks (deferred I/O), idle/prepare (internal), poll (I/O events, where it blocks waiting), check (setImmediate), close (close handlers). Between every phase — and after every callback — the microtask queue drains first: process.nextTick callbacks run before Promise.then callbacks. That means setTimeout(fn, 0) does not run before a resolved Promise: microtasks win. setImmediate vs setTimeout(0): setImmediate fires in the check phase of the current iteration; setTimeout(0) in the timers phase of the next — so inside an I/O callback, setImmediate reliably runs first.
Legacy vs modern
setTimeout(0) vs setImmediate vs microtasks ordering
Relying on timer ordering produces race-prone output; knowing the phases and microtask drain explains and fixes the ordering deterministically.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));queueMicrotask(() => console.log('microtask'));
setImmediate(() => console.log('immediate'));
setTimeout(() => console.log('timer'), 1);Interview gotchas
Context
This question distinguishes candidates who memorized 'nextTick is bad for recursion' from those who understand the drain semantics of the microtask queue.
The mechanical answer
Both nextTick and promise microtasks are drained completely — until empty — before the loop proceeds to the next phase (nextTick runs before the promise microtask checkpoint, but both drain fully). If each callback re-queues itself, the queue never empties, so the timers/I/O phases never run: the process spins on microtasks forever and I/O events never get processed (CPU pegged, but no timers fire). process.nextTick is slightly worse because its queue is drained before promise microtasks in the same turn. The safe pattern is to bound the recursion, or convert to setImmediate when the loop must yield to I/O between steps.
Trap
The trap answer is 'recursive promises are fine because they yield'. Promise.then callbacks are still microtasks drained to completion; they do not yield to the event loop's I/O phases. Another trap: 'process.nextTick is just a faster setTimeout' — it is a microtask queue in the C++ layer, not a timer at all. Naming the drain-to-empty semantics for BOTH queues is the differentiator.
Context
This checks real understanding of how Node stays alive and processes I/O — the interviewer wants libuv mechanics, not phase names.
The mechanical answer
In the poll phase, libuv calls the OS event demultiplexer (epoll_wait/kqueue/select) with a computed timeout: the time until the earliest timer (so timers are not late) or infinity if no timers/handles are scheduled. The poll returns ready I/O events; their callbacks run (draining microtasks after each). The loop exits when there are no more active handles (timers, sockets, etc.) and no pending work — that is why a server with an open socket stays alive while a plain script ends after its last tick. The poll timeout calculation is the mechanism that reconciles 'block for I/O' with 'never be late for timers'.
Trap
The naive answer is 'the poll phase blocks forever until I/O arrives'. It blocks only with a timeout computed from the timer heap; if a 0ms timer is pending, poll returns immediately. Another trap: 'Node exits when the main function returns' — it exits when the event loop has no active handles, which is why a leaked timer or socket keeps the process alive (the 'why won't my script exit' production bug). Mentioning handle counting and the poll timeout formula is the senior answer.