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

  1. timers — expired setTimeout / setInterval callbacks.
  2. pending callbacks — a few system-level callbacks (e.g. some TCP errors).
  3. idle / prepare — internal only.
  4. 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.
  5. checksetImmediate callbacks.
  6. 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

Example · javascript
// 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.

Example · js
// 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 -> check

Poll phase blocking explained

Illustrates that the poll phase blocks (up to the next timer deadline) waiting for I/O completions like `readFile`.

Example · js
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.

Example · js
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

  • Be the first to comment on this lesson.