Part 2 · 4 chapters · ~24 min

libuv and the Event Loop, For Real

libuv's architecture of loops, handles and requests, the phases of one loop iteration, the poll timeout calculation, the exact ordering of nextTick, microtasks and phases, platform IO (epoll, kqueue, IOCP, io_uring), the threadpool and its DNS surprise, blocking the loop and event loop utilisation, and how timers are implemented.

7

Loops, handles, requests and the phases

libuv has three nouns. A loop (uv_loop_t) runs until nothing is alive. A handle is a long-lived object that can produce callbacks repeatedly (a TCP server, a timer, a signal watcher); an active, referenced handle keeps the loop alive. A request is a one-shot operation (a write, a getaddrinfo, a file read) that completes once.

code
// handles keep the process alive; unref() opts out
const t = setInterval(() => {}, 1000);
t.unref();                     // the process can now exit even though the interval exists
process.getActiveResourcesInfo();   // ['TTYWrap', 'Timeout', ...]: what is keeping you alive

The poll timeout is the part people get wrong. Before blocking, libuv computes: 0 if there are pending immediates or the loop is stopping; otherwise the time until the nearest timer; otherwise infinite (block until IO). That is why an idle Node process uses no CPU, and why a timer firing late usually means the loop was busy running callbacks, not that the kernel was slow.

THE LOOP, PHASE BY PHASE
one iteration of uv_run, with the JavaScript queues drained between every callback
next tick of looptimerssetTimeout/Intervalpendingdeferred IO cbsidle, prepareinternalcloseclose eventschecksetImmediatepollwait for IO
swipe the figure sideways, or tap expand for full screen
1/6
timers
The loop starts by running expired timers: callbacks whose threshold has passed, in order of expiry. A timer is a minimum delay, never an exact one.
run timers whose time has comesetTimeout(fn, 10) means "not before 10 ms"
8

nextTick, microtasks and phase queues

code
console.log('1 sync');
process.nextTick(() => console.log('3 nextTick'));
Promise.resolve().then(() => console.log('4 microtask'));
setTimeout(() => console.log('5 or 6 timeout'), 0);
setImmediate(() => console.log('5 or 6 immediate'));
console.log('2 sync');

require('node:fs').readFile(__filename, () => {
  setTimeout(() => console.log('B timeout'), 0);
  setImmediate(() => console.log('A immediate'));    // always first: check comes right after poll
});

In ESM the module body itself runs inside a microtask job, so nextTick and promise ordering at the top level of an .mjs file differs from CommonJS: the promise reaction can run before the nextTick. Never depend on ordering between these queues for correctness; use explicit sequencing.

NEXTTICK, MICROTASKS AND PHASES
the exact order of a short script
main scriptnextTick queuemicrotasksloop phaseslog sync
swipe the figure sideways, or tap expand for full screen
1/5
synchronous first
All synchronous code in the main script runs to completion before anything queued runs.
sync code always finishes firstnothing interrupts JavaScript
9

Platform IO and the threadpool

platformmechanismmodel
Linuxepollreadiness: "this socket can be read now"
macOS, BSDkqueuereadiness, also files and processes
WindowsIOCPcompletion: "this read has finished"
Linux (newer)io_uringcompletion via shared rings; libuv uses it for some fs operations where available, and it has been switched off by default at times because of security concerns

Readiness APIs do not work for regular files (a file is always "ready"), which is the historical reason file IO goes to the threadpool.

THE THREADPOOL
four threads by default, shared by fs, dns.lookup, crypto and zlib
submitmain threadyour JSwork queueFIFOthread 1fs.readFilethread 2dns.lookupthread 3crypto.pbkdf2thread 4zlib.gzip
swipe the figure sideways, or tap expand for full screen
1/5
who uses it
Network sockets do not use the threadpool; they use kernel readiness (epoll, kqueue). The threadpool serves operations without a good async OS API: most fs calls, dns.lookup (getaddrinfo), crypto.pbkdf2/scrypt/randomBytes async forms, and zlib async methods.
sockets: kernel; fs, dns.lookup, crypto, zlib: threadpoolnetwork IO never touches the pool
10

Blocking the loop, ELU and timers

code
// measure event loop health from inside the process
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
const h = monitorEventLoopDelay({ resolution: 10 }); h.enable();
let last = performance.eventLoopUtilization();
setInterval(() => {
  const now = performance.eventLoopUtilization();
  const elu = performance.eventLoopUtilization(now, last); last = now;
  console.log({ elu: elu.utilization.toFixed(2), p99_delay_ms: (h.percentile(99) / 1e6).toFixed(1) });
  h.reset();
}, 5000);
blocks the loopfix
JSON.parse/stringify of multi-MB payloadsstream parsing, smaller payloads, workers
synchronous crypto, *Sync fs calls in request pathsasync versions; workers for CPU
catastrophic regex backtrackingsafe patterns, input limits, RE2
large loops over arrays per requestpagination, chunking with setImmediate, workers

Event loop utilisation (ELU) is the fraction of time the loop spent running callbacks rather than idle in poll. CPU percentage misleads (the threadpool and GC count too); ELU near 1 means the loop is saturated and latency is about to climb. Export it as a metric (part 11) and autoscale on it.

Timers: Node keeps timers in lists keyed by duration, ordered in a priority queue by expiry, so creating thousands of timers with the same duration is cheap. setTimeout(fn, 0) is clamped to 1 ms, runs only in the timers phase, and drifts by however long the previous iteration took.