The Event Loop, Really
A phase-by-phase mental model of libuv's loop and where the thread pool fits.
You already know Node has an event loop. As a senior engineer, the value is in knowing exactly where a given callback lands and why. The loop is provided by libuv, and each turn (a tick) walks a fixed sequence of phases, draining one queue per phase.
The phases, in order
- timers — expired
setTimeout/setIntervalcallbacks. - pending callbacks — a few system-level callbacks (e.g. some TCP errors).
- idle / prepare — internal only.
- poll — the heart: retrieve new I/O events and run their callbacks. If nothing is pending, the loop may block here waiting for I/O.
- check —
setImmediatecallbacks. - close callbacks — e.g.
socket.on('close').
The part people miss: the thread pool
The loop itself is single-threaded, but libuv keeps a small thread pool (default 4, set by UV_THREADPOOL_SIZE) for work that has no async OS primitive: fs operations, DNS lookup, and the crypto / zlib CPU functions. Network I/O does not use the pool — it uses the OS event notification (epoll/kqueue/IOCP) directly.
// Four crypto hashes saturate the default pool of 4 threads.
// A fifth waits for a thread to free up.
const crypto = require('node:crypto');
const start = Date.now();
for (let i = 0; i < 5; i++) {
crypto.pbkdf2('secret', 'salt', 100000, 64, 'sha512', () => {
console.log(i, Date.now() - start, 'ms');
});
}
// The 5th callback lands noticeably later than the first four.Understanding this tells you why UV_THREADPOOL_SIZE matters for hashing-heavy or file-heavy services, and why a blocked pool thread never blocks your HTTP sockets.
Example
// Prove the phase ordering with I/O in the mix.
// Inside an I/O callback, setImmediate ALWAYS beats setTimeout(0),
// because we are in the poll phase and 'check' comes right after.
const fs = require('node:fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout (timers phase, next tick)'), 0);
setImmediate(() => console.log('immediate (check phase, this tick)'));
});
// Deterministic output:
// immediate (check phase, this tick)
// timeout (timers phase, next tick)
//
// At the TOP LEVEL (no I/O context) the order of setTimeout(0) vs
// setImmediate is NOT guaranteed — it depends on how fast the loop
// starts relative to the 1ms timer clamp. Never rely on it there.When to use it
- A platform engineer traces why `setTimeout(fn, 1)` fires 20 ms late under load by understanding that the poll phase can block while awaiting I/O events.
- A library developer uses `setImmediate` inside a recursive callback to ensure other pending I/O callbacks get a turn between iterations.
- A performance researcher measures per-phase timing using `perf_hooks` to identify which event-loop phase is consuming the most wall-clock time.
More examples
Event-loop phase ordering
Shows that nextTick and microtasks drain before any phase, then timers run before the check (setImmediate) phase.
// Demonstrates the canonical phase order
setTimeout( () => console.log('1 timers'), 0);
setImmediate(() => console.log('3 check'));
process.nextTick(() => console.log('0 nextTick'));
Promise.resolve().then(() => console.log('0b microtask'));
console.log('sync');
// sync -> nextTick -> microtask -> timers -> checkPoll phase blocking explained
Illustrates that the poll phase blocks (up to the next timer deadline) waiting for I/O completions like `readFile`.
const fs = require('fs');
// The poll phase waits here for I/O if the queue is empty
// and a timer is not yet due
fs.readFile('big.txt', 'utf8', (err, data) => {
// This runs in the poll phase when the read completes
console.log('bytes:', data.length);
});Measure phase timing with perf_hooks
Uses `monitorEventLoopDelay` to measure mean and 99th-percentile event-loop latency over a 5-second window.
const { monitorEventLoopDelay } = require('perf_hooks');
const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();
setTimeout(() => {
h.disable();
console.log('mean delay:', h.mean / 1e6, 'ms');
console.log('p99 delay:', h.percentile(99) / 1e6, 'ms');
}, 5000);
Discussion