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.
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.
// 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 aliveThe 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.
nextTick, microtasks and phase queues
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.
Platform IO and the threadpool
| platform | mechanism | model |
|---|---|---|
| Linux | epoll | readiness: "this socket can be read now" |
| macOS, BSD | kqueue | readiness, also files and processes |
| Windows | IOCP | completion: "this read has finished" |
| Linux (newer) | io_uring | completion 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.
Blocking the loop, ELU and timers
// 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 loop | fix |
|---|---|
JSON.parse/stringify of multi-MB payloads | stream parsing, smaller payloads, workers |
synchronous crypto, *Sync fs calls in request paths | async versions; workers for CPU |
| catastrophic regex backtracking | safe patterns, input limits, RE2 |
| large loops over arrays per request | pagination, 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.