Explains surprising callback orders, starved I/O, and why some 'async' operations are slower than others.
Node's event loop, provided by libuv, cycles through phases, each with a queue of callbacks:
- timers — expired
setTimeout/setIntervalcallbacks. - pending callbacks — some deferred system errors.
- poll — waits for new I/O events and runs their callbacks (socket data, completed file reads). If nothing else is scheduled, the loop blocks here.
- check —
setImmediatecallbacks. - close callbacks —
'close'events of handles such as sockets.
Between every individual callback, Node drains two higher-priority queues: first the `process.nextTick` queue, then the promise microtask queue (then callbacks, code after await). That is why a promise continuation always runs before the next timer, and why recursive nextTick (or an endless chain of resolved promises) can starve I/O completely.
Practical consequences:
- Inside an I/O callback,
setImmediatealways runs beforesetTimeout(fn, 0), because check comes right after poll. From the main module their order is not guaranteed. setTimeout(fn, 10)means "not before 10 ms", never "exactly at 10 ms".- Prefer
queueMicrotaskorsetImmediateoverprocess.nextTickunless you specifically need to run before any other async work.
Network I/O uses the OS's non-blocking mechanisms (epoll, kqueue, IOCP) directly. But file system calls, dns.lookup, and CPU-heavy crypto/zlib work run on libuv's thread pool, which has only 4 threads by default (UV_THREADPOOL_SIZE, max 1024, set before startup). Many concurrent fs or pbkdf2 calls queue behind each other there — a frequent hidden bottleneck.
Measure loop health with perf_hooks.monitorEventLoopDelay(); consistently high delay means something is blocking.
Example
import { readFile } from "node:fs";
readFile(import.meta.filename, () => { // we are now in a poll-phase callback
console.log("1 sync");
setTimeout(() => console.log("6 timeout"), 0);
setImmediate(() => console.log("5 immediate")); // check phase comes right after poll
Promise.resolve().then(() => console.log("3 promise"));
queueMicrotask(() => console.log("4 microtask"));
process.nextTick(() => console.log("2 nextTick"));
});
// 1 sync, 2 nextTick, 3 promise, 4 microtask, 5 immediate, 6 timeout
// Gotcha: the body of an ES module itself runs inside a promise job, so at the
// top level of an .mjs file promise callbacks print BEFORE nextTick callbacks,
// and setTimeout(0) vs setImmediate order is not guaranteed there.Remember
- Phases: timers → pending → poll → check → close
- nextTick then promise microtasks run between every callback
- fs, dns.lookup, crypto and zlib use libuv's 4-thread pool by default
- Starving the loop with microtasks blocks I/O
Watch out for
- Using
process.nextTickrecursively and starving I/O. - Running hundreds of concurrent fs or crypto calls and blaming the disk for thread-pool queuing.