# The Node.js Event Loop, Actually Explained (No More Blocking-Diagram Hand-Waving)
TL;DR: The Node.js event loop is a libuv construct that cycles through six ordered phases — timers, pending callbacks, idle/prepare, poll, check, close callbacks — and between every phase transition (and every synchronous callback) it fully drains two separate queues, process.nextTick() first, then the V8 microtask queue (promises), before moving on; the phase order is why setImmediate() reliably fires before a setTimeout(fn, 0) scheduled inside an I/O callback, but not necessarily at the top level of your script.
Most explanations of this stop at a circle-with-six-boxes diagram and a vague claim that "timers run first." That gets you through an interview question, but it doesn't tell you why your setTimeout(fn, 0) sometimes fires before setImmediate() and sometimes after, or why a process.nextTick() call inside a promise .then() can starve I/O if you're not careful. This piece walks through the actual phases with a real script and traces exactly what libuv and V8 do, line by line.
The single-threaded thing you already know, restated correctly
Node.js runs your JavaScript on one thread. It is not single-threaded overall — libuv maintains a thread pool (default size 4, configurable via UV_THREADPOOL_SIZE) for things like filesystem operations, DNS lookups (dns.lookup), and some crypto functions. But your callbacks all execute on the main thread, one at a time, coordinated by the event loop.
The event loop itself is a C library called libuv, which Node embeds. libuv's design doc describes the loop as iterating through a fixed sequence of phases each "tick" of the loop, and it will keep iterating as long as there's a pending timer, active handle, or active request.
The six phases, in the order they actually run
Per libuv's own design documentation and the Node.js team's own event loop guide, each iteration of the loop runs through these phases in this order:
1. Timers — executes callbacks scheduled by setTimeout() and setInterval() whose threshold has elapsed.
2. Pending callbacks — executes I/O callbacks deferred to the next loop iteration, e.g. certain TCP error callbacks that some *nix systems want to report on the next tick rather than immediately.
3. Idle, prepare — used internally by libuv; not something application code hooks into directly.
4. Poll — retrieves new I/O events and executes I/O-related callbacks (almost all of them, except close callbacks, timer callbacks, and setImmediate()). This is where the loop can block waiting for I/O if there's nothing else to do.
5. Check — this is where setImmediate() callbacks run, specifically after poll, which is the entire reason setImmediate exists as a separate primitive from setTimeout(fn, 0).
6. Close callbacks — socket.on('close', ...) and similar close-event handlers.
Between each of those phase transitions — and after every single callback that runs synchronously — Node checks two internal queues and fully drains them before continuing: the process.nextTick() queue first, then the microtask queue that V8 uses for promise reactions and anything scheduled with queueMicrotask(). This detail is what trips people up, because it means "microtasks run between phases" is true but incomplete — they also run between individual callbacks within the same phase.
A real trace, not a diagram
Save this as trace.js and run it with node trace.js:
const fs = require('fs');
console.log('1: sync start');
setTimeout(() => console.log('6: timeout (timers phase)'), 0);
setImmediate(() => console.log('7: immediate (check phase)'));
fs.readFile(__filename, () => {
console.log('8: fs.readFile callback (poll phase)');
setTimeout(() => console.log('11: timeout scheduled from poll'), 0);
setImmediate(() => console.log('10: immediate scheduled from poll'));
process.nextTick(() => console.log('9: nextTick scheduled from poll'));
});
process.nextTick(() => console.log('3: nextTick 1'));
process.nextTick(() => console.log('4: nextTick 2'));
Promise.resolve().then(() => console.log('5: promise 1'));
console.log('2: sync end');Here's what happens, phase by phase:
- Synchronous run:
1and2print immediately as the script body executes top to bottom.setTimeout,setImmediate, andfs.readFilejust register their callbacks with libuv and return. - Checkpoint after the script finishes: Node drains
process.nextTickfirst, so3and4print, then drains the microtask queue, so5prints. - Timers phase: the loop checks for expired timers. The
setTimeout(fn, 0)from the top level is now due, so6prints. (Theprocess.nextTick/microtask checkpoint runs again here, but the queues are empty.) - Poll phase: libuv checks for completed I/O. The thread pool has finished reading the file, so its callback fires:
8prints. Inside that callback, a newsetTimeout,setImmediate, andnextTickare scheduled. Immediately after this synchronous callback finishes, Node drainsnextTickagain —9prints, right there in the middle of the poll phase, before the loop moves anywhere else. - Check phase:
setImmediatecallbacks run here. Two are queued — the top-level one and the one scheduled from inside thefs.readFilecallback — so7and10print in that order (registration order within the phase). - Next loop iteration, timers phase: the
setTimeoutscheduled insidefs.readFileis now due, so11prints.
Final output order: 1, 2, 3, 4, 5, 6, 8, 9, 7, 10, 11.
The detail worth internalizing: setImmediate() scheduled from inside an I/O callback (poll phase) is guaranteed to run before any setTimeout(fn, 0) scheduled at the same point, because check comes right after poll in the same iteration, while the timer has to wait for the next timers phase. That guarantee does not hold for timers and immediates scheduled at the top level of a script (outside any I/O callback) — there, the order depends on how long process startup took relative to the timer's 1ms floor, which is why you'll see people online swear the order "changes every run." It doesn't change arbitrarily; it's just not deterministic in that specific case, and the Node.js docs say so explicitly.
Why `process.nextTick` can starve your event loop
Because the nextTick queue is drained completely — including any new callbacks added to it while draining — before the loop moves on, recursively calling process.nextTick() from within a nextTick callback will block the event loop forever. This isn't a theoretical footgun; it's a documented failure mode, and it's the reason the Node.js docs recommend setImmediate() when you need to yield back to the loop rather than just defer execution. The same applies, with slightly different mechanics, to chained promise .then() calls that keep re-resolving.
// Don't do this — it never lets the loop reach the poll or check phase
function recurse() {
process.nextTick(recurse);
}
recurse();Quick reference
| Phase | What runs there | API surface |
|---|---|---|
| Timers | Expired setTimeout/setInterval callbacks | setTimeout, setInterval |
| Pending callbacks | Deferred system-level I/O callbacks | mostly internal (e.g. some TCP errors) |
| Poll | I/O callbacks, can block waiting for events | fs.*, network sockets, thread-pool results |
| Check | Callbacks deferred to "after poll" | setImmediate |
| Close callbacks | Cleanup for closed handles | 'close' event handlers |
| (between every phase/callback) | Node-internal then V8-internal microtasks | process.nextTick, promises, queueMicrotask |
Where this actually matters in practice
This isn't just trivia for a whiteboard. It matters for real decisions: whether to break up a CPU-heavy loop with setImmediate() so the server can still answer health checks, whether a retry-on-promise-rejection pattern is quietly starving I/O, and how to reason about ordering in tests that mix timers, promises, and file/network calls. If you're debugging a Node service that feels "stuck" under load despite low CPU, the poll phase and thread-pool saturation (UV_THREADPOOL_SIZE) are usually where to look first, not the timers.
If you're building or scaling a Node.js backend and want a second set of eyes on where event-loop or thread-pool bottlenecks are actually coming from, that's the kind of systems-level diagnosis our web development team does for clients regularly.
FAQ
Is `setImmediate()` always faster than `setTimeout(fn, 0)`?
Not "faster" in a raw sense — both fire on the next available opportunity. The guarantee is about ordering, not speed: setImmediate() scheduled inside an I/O callback runs before a setTimeout(fn, 0) scheduled at the same point, because check follows poll in the same loop iteration. At the top level of a script, outside any I/O callback, the order between the two is not guaranteed.
Does `await` block the event loop?
No. await suspends the current async function and returns control to the event loop; the continuation after the await is scheduled as a microtask once the awaited promise settles. What does block the loop is synchronous CPU-bound work — a tight for loop, synchronous JSON parsing of a huge payload, crypto calls run synchronously, etc.
What's the difference between the microtask queue and the macrotask (phase) queues?
Microtasks — process.nextTick() callbacks and promise reactions — are drained completely between every phase transition and after every individual callback. Phase queues (timers, poll, check, etc.) are only processed once per loop iteration, in their fixed order. That's why microtasks can appear to "cut in line" ahead of macrotasks even when a macrotask was scheduled first.
Can I see which phase the loop is currently in?
Not directly through public API. Tools like node --prof plus node --prof-process, the built-in perf_hooks module, or the async_hooks API can give you visibility into callback timing and async resource lifecycles, but there's no first-class "current phase" introspection API in Node core.