The Node.js Event Loop, Demystified
A precise tour of the libuv event loop phases, the microtask queue, nextTick vs setImmediate vs setTimeout(0) ordering, and why one CPU-bound call freezes every request.
Node runs your JavaScript on a single thread, yet it serves thousands of concurrent connections. The trick is that the thread spends almost all its time waiting, and libuv schedules what runs when it stops. Get the scheduling model right and most "weird async ordering" bugs and "the server froze" incidents stop being mysteries.
1. The phases of one tick
libuv drives the loop. Each iteration (a tick) walks a fixed set of phases, and each phase has its own FIFO callback queue:
timers setTimeout / setInterval callbacks whose threshold elapsed
pending callbacks some deferred system/TCP error callbacks
idle, prepare internal use only
poll retrieve I/O events; run I/O callbacks; may BLOCK here
check setImmediate callbacks
close callbacks socket.on('close'), etc.
The phase that matters most is poll. This is where the loop sleeps on the OS event mechanism (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) waiting for sockets, timers, and completed thread-pool work. If the poll queue is empty and no setImmediate callbacks are scheduled, the loop blocks in poll rather than spinning — that is what lets an idle Node process use roughly 0% CPU. (If setImmediate work is queued, the loop does not block; it ends poll and moves to check.) When an I/O event is ready, its callback runs inside the poll phase.
One caveat: since libuv 1.45.0 (Node 20) the timers queue is evaluated only after the poll phase completes, not both before and after as older diagrams imply. The phase order above is unchanged.
2. Microtasks drain between callbacks
The phase queues above hold macrotasks. Separate from them are two microtask queues that Node drains between macrotasks — since Node 11, not only at phase boundaries but after each individual callback completes:
- The nextTick queue — callbacks passed to
process.nextTick(). - The microtask queue — resolved Promise continuations (
.then/await) andqueueMicrotask()callbacks.
The ordering rule that trips people up: after every callback returns, Node fully drains the nextTick queue first, then fully drains the Promise microtask queue, and only then moves to the next callback or phase. "Fully" is recursive — if a microtask schedules another microtask, that one runs before the loop advances.
// CommonJS module
setTimeout(() => console.log('timeout'), 0);
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('sync');
// sync
// nextTick (nextTick drains before the Promise microtask)
// promise
// timeout (next tick, timers phase)
The synchronous top-level code is itself the first "task". When it finishes, Node drains nextTick (nextTick), then the microtask queue (promise), and only then reaches the timers phase (timeout). One subtlety: this nextTick-before-Promise rule holds inside normal callbacks and CommonJS modules (as here). During synchronous evaluation of an ES module Node is already draining microtasks, so a Promise callback can run ahead of process.nextTick; don't depend on the two queues' relative order during module evaluation.
3. nextTick vs setImmediate vs setTimeout(0)
These three look interchangeable and are not.
process.nextTick(fn)— runs before the loop continues, after the current operation. It is not a phase; it is a microtask that drains ahead of Promises. RecursivenextTickcan starve the loop entirely because the loop never advances a phase.setImmediate(fn)— runs in the check phase, right after the poll phase of the current tick.setTimeout(fn, 0)— runs in the timers phase of a future tick. The0is clamped to a 1 ms minimum inside Node.
The famous race is between the last two at the top level:
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// Order is NOT guaranteed at the top level.
At startup the order is non-deterministic: it depends on whether the timer's 1 ms threshold has elapsed by the time the loop first reaches the timers phase, which depends on how busy the process was getting there. But put the same two calls inside an I/O callback and the order becomes deterministic:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
// Always:
// immediate
// timeout
The readFile callback runs in the poll phase. The very next phase is check, so setImmediate fires this same tick; the timer must wait for the timers phase of the next tick. That is the answer interviewers listen for: inside an I/O callback, setImmediate always beats setTimeout(0), because check immediately follows poll.
4. Why one CPU-bound function freezes everything
There is exactly one thread executing your JavaScript, and the loop only advances when your callback returns. So a synchronous CPU-bound call doesn't just slow its own request — it blocks the loop, so no timers fire, no I/O callbacks run, and every other in-flight request stalls until it returns.
const crypto = require('crypto');
// Synchronous and CPU-heavy. While this runs, the loop is frozen.
function badHash(password, salt) {
return crypto.pbkdf2Sync(password, salt, 600000, 32, 'sha256');
}
app.post('/login', (req, res) => {
const key = badHash(req.body.password, req.body.salt); // blocks the thread
res.json({ ok: timingSafeMatch(key, storedKey) });
});
One slow pbkdf2Sync on the request thread freezes health checks, other users' logins, and any timer-driven work. This is the canonical "Node fell over under load" cause: not too much I/O, but a hot synchronous path. The fix is to move the CPU work off the loop thread:
// Fix: the async variant offloads to the libuv thread pool.
const { pbkdf2 } = require('crypto');
const { promisify } = require('util');
const pbkdf2Async = promisify(pbkdf2);
app.post('/login', async (req, res) => {
const key = await pbkdf2Async(
req.body.password, req.body.salt, 600000, 32, 'sha256');
res.json({ ok: timingSafeMatch(key, storedKey) }); // loop stays free
});
For pure-JS CPU work (parsing, image math, big JSON transforms) there is no async stdlib variant — offload to a worker_threads Worker, or break the work into chunks scheduled with setImmediate so the loop can interleave other callbacks between chunks. The mental check: can this function run for more than a few milliseconds without yielding? If yes, it does not belong on the loop thread.
5. The thread pool: how fs/crypto/dns stay async
"Single-threaded" describes your JavaScript, not the process. libuv keeps a thread pool (default size 4) for operations with no non-blocking kernel API. Work submitted to the pool runs on a real OS thread; when it finishes, its callback is queued back to the loop and runs in the poll phase. The pool serves:
- File system — almost all
fs.*async calls. - crypto —
pbkdf2,scrypt,randomBytes,randomFill,generateKeyPair. - DNS —
dns.lookup()(which callsgetaddrinfo); thedns.resolve*()family uses c-ares over the network and does not use the pool. - zlib — every async (non-
*Sync) compression/decompression call.
Network sockets do not use the pool — epoll/kqueue/IOCP give the kernel a non-blocking path, so TCP/HTTP scales independently of pool size. The pool size is read once when the pool is first used, from an env var:
# Set before launching the process.
UV_THREADPOOL_SIZE=8 node server.js
The default of 4 means only four pool operations run at once; the rest queue. Fire 100 concurrent pbkdf2 hashes and they execute four at a time while the others wait — latency climbs even though the loop looks idle. The same holds for heavy fs or dns.lookup fan-out. Sizing rule: raise UV_THREADPOOL_SIZE toward your core count when genuinely pool-bound, and prefer DNS caching or dns.resolve* to avoid loading the pool with lookups.
6. Putting it together: a full ordering trace
const fs = require('fs'); // CommonJS
console.log('1: sync start');
setTimeout(() => console.log('5: timeout'), 0);
setImmediate(() => console.log('6: immediate'));
fs.readFile(__filename, () => {
console.log('7: read (poll)');
setTimeout(() => console.log('A: inner timeout'), 0);
setImmediate(() => console.log('9: inner immediate'));
process.nextTick(() => console.log('8: inner nextTick'));
});
Promise.resolve().then(() => console.log('4: promise'));
process.nextTick(() => console.log('3: nextTick'));
console.log('2: sync end');
// 1, 2 : synchronous
// 3 : nextTick drains before the Promise microtask
// 4 : promise microtask
// 5, 6 : timers vs check (top level: order may vary by timing)
// 7 : readFile callback in poll
// 8 : inner nextTick drains right after that callback
// 9 : inner setImmediate (check, same tick)
// A : inner setTimeout (timers, next tick)
Lines 1–4 are deterministic and show microtask precedence; lines 7–A are deterministic and show why setImmediate beats setTimeout(0) once you are inside an I/O callback. Only the top-level 5/6 pair is timing-dependent.
Takeaways
- nextTick > Promises > macrotask phases. Both microtask queues fully drain after every callback (Node 11+); the nextTick queue goes first in CommonJS and normal callbacks.
- Inside I/O,
setImmediatealways beatssetTimeout(0)because check follows poll. At the top level, treat their order as undefined. - Never put unbounded CPU work on the loop thread. One synchronous hot path freezes every concurrent request — offload to async stdlib calls or
worker_threads. - fs / crypto / dns.lookup / zlib use the libuv thread pool (default 4); sockets and
dns.resolve*do not. TuneUV_THREADPOOL_SIZEwhen pool-bound, not when network-bound. - Avoid recursive
process.nextTick— it can starve the loop and stall I/O indefinitely. Reach forsetImmediatewhen you want to yield.