Engineering Notes
Home/Backend/Node.js/Node's Event Loop (libuv)
Backend · Node.js

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.

⚙️ Backend● Advanced⏱ 9 min read▶ 2 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 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
Part 1Mapping Node's APIs to the Phases

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

PhaseWhat actually runs hereNode APIs
timerscallbacks whose scheduled time has passed, pulled from a min-heap of (expiry, callback) pairssetTimeout, setInterval
pending callbackssome system-level callbacks deferred from the previous loop iteration (e.g. certain TCP errors)mostly internal
idle, prepareinternal libuv bookkeepingnone 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 scheduledfs.* (threadpool results land here), net/http socket readable/writable events
checksetImmediate callbacks, specifically, always after pollsetImmediate
close callbackscleanup callbacks for things closed abruptlysocket.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.

js
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 blocking read() 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.
  • net socket 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.
fs.readFile takes the threadpool road into the poll phase; a net socket read takes the OS epoll/kqueue road directly into the poll phase, without touching the threadpool
fs.readFile takes the threadpool road into the poll phase; a net socket read takes the OS epoll/kqueue road directly into the poll phase, without touching the threadpool

3setImmediate — Deliberately a Separate Phase From timers

js
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

  1. Assuming all async Node APIs work the same way — fs and net both "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).
  2. Expecting setImmediate and setTimeout(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.
  3. 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.
  4. 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

text
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 phase
Part 2process.nextTick, Full Ordering, and Threadpool Tuning

Part 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.

js
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

text
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.

js
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

js
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

bash
UV_THREADPOOL_SIZE=8 node server.js
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/http don'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).
text
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
nextTick and microtask queues drain completely between every callback, nested inside the six-phase libuv cycle, not just once per phase
nextTick and microtask queues drain completely between every callback, nested inside the six-phase libuv cycle, not just once per phase

⚠️ Common Pitfalls

  1. Treating process.nextTick as "the same as" setImmediate — the names invite confusion, but they're opposite ends of the priority spectrum: nextTick runs before even microtasks, setImmediate runs in the check phase, one of the last things each cycle.
  2. Recursively scheduling process.nextTick can 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 because nextTick is drained with even higher priority than microtasks.
  3. Relying on setTimeout(fn, 0) vs setImmediate() ordering at the top level of a script — only reliable inside an I/O callback (Part 1 §3); flaky at the top level.
  4. Raising UV_THREADPOOL_SIZE to "fix" a slow server without profiling first — if the bottleneck is CPU-bound JS or network sockets, this env var changes nothing.

📋 Quick Reference

text
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

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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.

Follow-ups interviewers ask
  • 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).

Follow-ups interviewers ask
  • 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.

1. Which runs first: process.nextTick or Promise.then?
nextTick queue drains first.
2. Inside an fs callback, setImmediate vs setTimeout(0)?
Deterministic there.
3. When does the loop block in the poll phase?
Idle waiting.
4. A recursive process.nextTick loop causes…
The queue never empties.