Node's Event Loop (libuv)
The event loop is a fixed cycle of phases, with microtasks drained between every callback. Step through one loop iteration phase by phase, then predict the output of classic ordering puzzles.
🎯 What you'll learn
- What each phase runs and which Node APIs feed it
- Two roads into poll: OS async vs thread pool
- setImmediate vs setTimeout and process.nextTick
- Complete ordering and thread-pool tuning
📚 Before you start
This topic assumes you've read languages/javascript/27-event_loop (js_event_loop_theory_1.md and js_event_loop_theory_2.md). That topic already covers the generic call-stack/queue mechanism, and its "Under the Hood" section already introduced libuv's six phases by name (timers → pending callbacks → idle/prepare → poll → check → close callbacks). This topic does not re-introduce that list — it goes further: which specific Node APIs land in which phase, and why, plus the thread pool's role from an architectural standpoint. Part 2 covers process.nextTick, setImmediate vs setTimeout ordering in depth, and UV_THREADPOOL_SIZE tuning.
1The Phases Aren't Generic — Each One Is Backed By Specific Node Machinery
| Phase | What actually runs here | Node APIs |
|---|---|---|
| timers | callbacks whose scheduled time has passed, pulled from a min-heap of (expiry, callback) pairs | setTimeout, setInterval |
| pending callbacks | some system-level callbacks deferred from the previous loop iteration (e.g. certain TCP errors) | mostly internal |
| idle, prepare | internal libuv bookkeeping | none you call directly |
| poll | (a) retrieve new I/O events from the OS, (b) execute their callbacks; also where the loop blocks and waits if nothing else is scheduled | fs.* (threadpool results land here), net/http socket readable/writable events |
| check | setImmediate callbacks, specifically, always after poll | setImmediate |
| close callbacks | cleanup callbacks for things closed abruptly | socket.on('close', ...), server.close() |
2Two Fundamentally Different Roads Into the Poll Phase
The most important architectural fact this topic adds on top of the phase list: fs.readFile and a net socket read both eventually surface their callback in the poll phase, but they get there through completely different mechanisms.
const fs = require("fs");
const net = require("net");
fs.readFile(__filename, () => console.log("fs callback — poll phase, via THREADPOOL"));
net.createServer((socket) => {
socket.on("data", () => console.log("socket callback — poll phase, via OS EPOLL/KQUEUE"));
}); // (not started — illustrative)fs.readFile: Node hands the blockingread()syscall to a libuv threadpool worker thread. The worker blocks (that's fine — it's not the JS thread), and when it finishes, it posts a completion event back to the loop. The loop picks that up and runs your callback during the poll phase.netsocket data: libuv registers the socket file descriptor with the OS's native async I/O facility (epoll on Linux, kqueue on macOS/BSD, IOCP on Windows) via a single call in the poll phase itself. No thread pool worker is spent per socket — the OS itself notifies libuv "this fd is readable," and libuv fires the callback directly. This is architecturally why Node can hold open tens of thousands of idle sockets cheaply: it costs one file descriptor and a kernel-level registration, not a thread.
3setImmediate — Deliberately a Separate Phase From timers
const fs = require("fs");
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// inside an I/O callback, immediate ALWAYS logs first — deterministically
});setImmediate exists specifically to run "right after I/O, this same iteration," which is exactly what "check phase, right after poll" gives you. That's a different guarantee than setTimeout(fn, 0), which lands in the timers phase — a phase the loop only reaches at the top of its next full cycle. When both are scheduled inside an I/O callback (as above), the loop is already past timers for this cycle and is sitting in poll; it proceeds to check next, so setImmediate reliably wins. At the top level of a script (outside any I/O callback), which one wins depends on how much time has elapsed reaching that point relative to the timer's expiry — which is why that specific case is the famous "non-deterministic" one (Part 2 covers this ordering question in more depth alongside process.nextTick).
⚠️ Common Pitfalls
- Assuming all async Node APIs work the same way —
fsandnetboth "feel" async from JS, but one consumes a threadpool thread and one doesn't; this matters for capacity planning under load (see Part 1's Architecture and Runtime topic for the threadpool-sizing implications). - Expecting
setImmediateandsetTimeout(fn, 0)to have a fixed relative order everywhere — it's deterministic inside an I/O callback, not at the top level of a script. - Forgetting the poll phase can BLOCK — if nothing else is scheduled, the loop waits in poll for I/O rather than spinning; this is why an idle Node process uses ~0% CPU instead of busy-looping.
- Confusing "pending callbacks" (a specific phase for deferred system callbacks) with "any pending async operation" — it's a narrow, mostly-internal phase, not a general term.
📋 Quick Reference
fs.* → threadpool → poll phase
net/http sockets → OS epoll/kqueue/IOCP → poll phase (no threadpool)
setTimeout/Interval → timers phase (min-heap by expiry)
setImmediate → check phase (always right after poll)
socket 'close' → close callbacks phasePart 1 mapped Node's APIs onto libuv's six phases. This part covers the queue that ISN'T one of the six phases at all — process.nextTick — and pulls everything (phases, microtasks, nextTick) into one precise ordering model, then covers UV_THREADPOOL_SIZE tuning in practice.
1process.nextTick Is Not Part of libuv
This is the single most commonly misunderstood fact about Node's loop: process.nextTick has nothing to do with libuv's phases. It's a Node-specific queue, drained by Node's C++ layer — not a libuv construct at all — and it runs before Promise microtasks, and after every single callback, not just once per phase.
console.log("1: sync");
setTimeout(() => console.log("5: timeout"), 0);
process.nextTick(() => console.log("3: nextTick"));
Promise.resolve().then(() => console.log("4: promise microtask"));
console.log("2: sync");Output: 1, 2, 3, 4, 5 — nextTick beats the Promise microtask, and both beat the macrotask-equivalent timer, exactly matching the generic microtask-drains-before-macrotask rule from languages/javascript/27-event_loop, but with nextTick given strict priority even over Promise callbacks.
2The Complete, Precise Ordering
after EVERY callback that runs (not just once per phase, EVERY single one):
1. drain process.nextTick queue COMPLETELY (including new nextTicks added while draining)
2. drain the Promise microtask queue COMPLETELY (including new ones added while draining)
3. proceed to the next scheduled callback (may be same phase, or move to next phase)This nests inside the six-phase cycle from Part 1: every time the loop is about to move from one callback to the next — even within the same phase, not just at phase boundaries — it stops and fully drains nextTick, then microtasks, before continuing.
const fs = require("fs");
fs.readFile(__filename, () => {
console.log("A: fs callback (poll phase)");
process.nextTick(() => console.log("B: nextTick"));
Promise.resolve().then(() => console.log("C: microtask"));
setImmediate(() => console.log("D: setImmediate (check phase)"));
});Output: A, B, C, D — after the poll-phase callback (A) finishes, nextTick (B) and microtasks (C) drain completely before the loop even considers moving to the check phase for D.
3Why the Top-Level setTimeout vs setImmediate Race Is Genuinely Non-Deterministic
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));At the top level of a script, both timers and check are still ahead of the loop when it starts. Which one the loop reaches "first" this cycle depends on exactly how much wall-clock time elapsed between process start and the loop's first pass through the timers phase — if more than ~1ms has already passed (process startup, module loading), the 0ms timer is already "expired" and fires in the timers phase before the loop even reaches check; if not, the timer isn't expired yet, the loop moves on to poll/check first, and setImmediate wins. This is real OS scheduling jitter, not a bug — which is exactly why Node's own docs call this specific ordering "not guaranteed" only at the top level.
4Threadpool Sizing in Practice
UV_THREADPOOL_SIZE=8 node server.js// or, before ANY threadpool-using call in your entry file:
process.env.UV_THREADPOOL_SIZE = 8;Raising the pool size gives more concurrent slots for fs.*, dns.lookup, and async crypto calls to run without queueing behind each other — useful for I/O-heavy workloads issuing many concurrent filesystem operations. It does not help:
- Network-socket-heavy workloads (
net/httpdon't use the pool at all — see Part 1 §2). - CPU-bound JavaScript (a tight loop in your own JS still fully occupies the one JS thread; more pool threads doesn't give your JS more places to run — see the Worker Threads topic for actual parallel JS execution).
UV_THREADPOOL_SIZE too small → fs-heavy code queues, throughput caps well below CPU capacity
UV_THREADPOOL_SIZE raised → more concurrent fs/dns/crypto slots, up to the OS's own I/O limits
UV_THREADPOOL_SIZE raised, but bottleneck is CPU-bound JS → no improvement, wrong knob entirely⚠️ Common Pitfalls
- Treating
process.nextTickas "the same as"setImmediate— the names invite confusion, but they're opposite ends of the priority spectrum:nextTickruns before even microtasks,setImmediateruns in the check phase, one of the last things each cycle. - Recursively scheduling
process.nextTickcan starve the entire event loop (no phase, no I/O, no timer ever runs) — the same starvation risk the generic microtask topic describes, but worse here becausenextTickis drained with even higher priority than microtasks. - Relying on
setTimeout(fn, 0)vssetImmediate()ordering at the top level of a script — only reliable inside an I/O callback (Part 1 §3); flaky at the top level. - Raising
UV_THREADPOOL_SIZEto "fix" a slow server without profiling first — if the bottleneck is CPU-bound JS or network sockets, this env var changes nothing.
📋 Quick Reference
process.nextTick → Node-specific queue, NOT libuv, drained before microtasks, after every callback
Promise microtasks → drained after nextTick, still after every callback
six libuv phases → nested OUTSIDE the nextTick/microtask draining, not the other way around
setImmediate vs setTimeout(fn,0): deterministic inside I/O callbacks only (immediate wins)
UV_THREADPOOL_SIZE: helps fs/dns/crypto concurrency; does nothing for sockets or CPU-bound JS🧠 Key takeaways
- Phases: timers → pending → poll → check → close; poll waits for I/O when idle.
- Microtasks (nextTick first, then promises) drain after each callback.
- Inside I/O callbacks, setImmediate runs before setTimeout(0); from the main module it's a race.
- Recursive nextTick/microtasks starve I/O; CPU-heavy callbacks block every phase.
🎤 Interview Q&A
Q1Explain the phases of the Node.js event loop.core▾
timers (setTimeout/setInterval callbacks) → pending callbacks → idle/prepare → poll (I/O events; may block waiting) → check (setImmediate) → close callbacks. Between each callback, process.nextTick and promise microtasks run.
- setImmediate vs setTimeout(0)? Inside an I/O callback, setImmediate always runs first; from the main module, order is not guaranteed.
Q2What's the difference between process.nextTick and promise microtasks?core▾
Both run before the event loop continues; the nextTick queue is drained first, then the promise microtask queue. Recursive nextTick calls can starve I/O.
- Use case for nextTick? Deferring a callback until after the current operation but before I/O, e.g. emitting events after construction.
Q3What does libuv's thread pool do?senior▾
Runs operations that lack non-blocking OS APIs: file system operations, DNS lookup, crypto (pbkdf2, scrypt), zlib. Default size 4 (UV_THREADPOOL_SIZE). Network I/O uses OS async mechanisms (epoll/kqueue/IOCP) instead.
- Symptom of pool saturation? Slow file or crypto operations even though CPU is low.
Q4What blocks the event loop and how do you avoid it?core▾
Synchronous CPU work (large JSON parsing, regexes, crypto sync, loops over big arrays), sync fs calls. Avoid by chunking work, using streams, worker threads, or offloading to other services.
- How do you detect it? Event loop delay metrics and CPU profiles.
Q5What's the output order: setTimeout(log1), Promise.resolve().then(log2), process.nextTick(log3), log4?core▾
log4 (synchronous), log3 (nextTick), log2 (promise microtask), log1 (timer).
- Why does nextTick beat the promise? Node drains the nextTick queue before promise microtasks.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.