Concept
The beginner framing: Node.js runs your JavaScript on a single thread, just like a browser, but it also needs to talk to the operating system for things like file I/O, network sockets, and timers, and the way it coordinates all of that is the libuv event loop, a more elaborate mechanism than the browser's.
The precise mental model, building on Event Loop (JavaScript): the browser model of "call stack → microtasks → one macrotask → repeat" is a simplification that applies to browsers. Node.js's actual event loop, powered by libuv, cycles through six distinct phases every iteration, and on top of that has a third queue, process.nextTick, that doesn't fit the microtask/macrotask split at all.
The six libuv phases, in order
- timers, runs callbacks scheduled by
setTimeout/setIntervalwhose time has elapsed - pending callbacks, executes I/O callbacks deferred from the previous iteration (rare, mostly internal)
- poll, retrieves new I/O events (file reads, network data); executes their callbacks; the loop can block here waiting for I/O if there's nothing else scheduled
- check, runs
setImmediatecallbacks, specifically designed to run right after poll - close callbacks, runs callbacks like
socket.on('close', ...) - back to timers, starting a new iteration
const fs = require("fs");
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
fs.readFile(__filename, () => {
// we're now INSIDE the poll phase
setTimeout(() => console.log("timeout from poll"), 0);
setImmediate(() => console.log("immediate from poll"));
});Explore this step by step:
const fs = require('fs');setTimeout(() => console.log('timer'), 0);setImmediate(() => console.log('immediate'));fs.readFile(__filename, () => {console.log('poll: I/O callback');setTimeout(() => console.log('timer (from I/O)'), 0);setImmediate(() => console.log('immediate (from I/O)'));});
The script runs top-to-bottom, synchronously, scheduling four things: a timer (→ timers phase), an immediate (→ check phase), and a file read whose callback (→ poll phase) will itself schedule a NEW timer and a NEW immediate once it runs.
The key insight: a setImmediate scheduled while already inside poll (like inside the fs.readFile callback) runs in check, the very next phase, same iteration. A setTimeout(fn, 0) scheduled at that same moment has to wait for timers in the next iteration, since poll has already passed timers this time around. This is why, specifically inside an I/O callback, setImmediate is guaranteed to fire before a setTimeout(fn, 0) scheduled at the same moment.
process.nextTick: a queue with higher priority than promises
Node adds one more queue that isn't part of libuv's phases at all: process.nextTick. It drains completely, including any callbacks added to it during its own draining, before the Promise microtask queue gets any turn, and this happens between every single phase transition, not just once per tick.
console.log("start");
process.nextTick(() => {
console.log("nextTick 1");
process.nextTick(() => console.log("nextTick 3 (added DURING nextTick 2)"));
});
process.nextTick(() => console.log("nextTick 2"));
Promise.resolve().then(() => console.log("promise"));
setTimeout(() => console.log("timeout"), 0);
console.log("end"
console.log('start');setTimeout(() => console.log('timeout'), 0);Promise.resolve().then(() => console.log('promise'));process.nextTick(() => console.log('nextTick 1'));process.nextTick(() => {console.log('nextTick 2');process.nextTick(() => console.log('nextTick 3 (added DURING nextTick 2)'));});console.log('end');
console.log('start') runs synchronously.
Priority order, strictly: process.nextTick queue > Promise microtask queue > libuv phases (timers → pending callbacks → poll → check → close callbacks).
Non-determinism at the top level
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// which logs first? Depends on process startup timing, NOT guaranteed// Called from the TOP LEVEL (main module)setTimeout(() => console.log('timeout'), 0);setImmediate(() => console.log('immediate'));
At the top level, whether the event loop enters its first TIMERS phase with the 0ms timer already 'due' depends on how much time has actually elapsed since the process started, sometimes it has, sometimes it hasn't. The order between these two is genuinely NOT deterministic here.
At the top level of a script (not inside an I/O callback), whether setImmediate or setTimeout(fn, 0) fires first is not guaranteed, it depends on how long process initialization took before reaching the timers phase for the first time. Inside an I/O callback (already in poll), setImmediate reliably wins, as shown above.
Try It
Predict the exact output order before checking the solution.
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
process.nextTick(() => console.log("4"));
console.log("5");Solution
1, 5, 4, 3, 2.
Synchronous code runs first: 1, then 5. Then, before the event loop can proceed to any phase, the nextTick queue drains completely: 4. Then the Promise microtask queue drains: 3. Only then does the loop enter the timers phase and run the setTimeout callback: 2.
Implement It Yourself
Build a minimal simulator of the priority ordering (nextTick > microtasks > timers), without needing real timers or promises:
function simulateEventLoop(nextTickQueue, microtaskQueue, timerQueue) {
const output = [];
function drainNextTick() {
while (nextTickQueue.length > 0) {
const cb = nextTickQueue.shift();
cb(output, nextTickQueue); // callback may push MORE nextTicks, keep draining
}
}
function drainMicrotasks() {
while (microtaskQueue.length > 0) {
const cb = microtaskQueue.shift();
cb(output);
}
}
This mirrors the real priority rule: after any callback runs, nextTick drains fully, then microtasks drain fully, before the loop does anything else, including before moving on to the next timer callback.
Under the Hood
This topic directly extends Event Loop (JavaScript)'s call-stack/microtask/macrotask model, everything learned there about microtask-vs-macrotask priority still applies; libuv's six phases are simply a more detailed breakdown of what browsers loosely call "macrotasks," and process.nextTick is a Node-specific addition with no browser equivalent. EventEmitter's event-driven callback pattern (see EventEmitter & Async Patterns) is what many I/O completions ultimately surface through once they've been through this loop.
Common Mistakes
1. Assuming Node's event loop works exactly like the browser's
// "microtasks always run before the next macrotask", true in browsers,
// but Node also has process.nextTick, which has an EVEN higher priority
// and drains between EVERY phase, not just onceThe browser's two-queue mental model is incomplete for Node, the three-tier priority (nextTick > microtasks > libuv phases) is Node-specific.
2. Assuming setImmediate always beats setTimeout(fn, 0)
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// NOT guaranteed at the top level, only guaranteed inside an I/O callbackThis ordering is only deterministic inside an I/O callback (already in the poll phase). At the top level, it depends on startup timing.
3. Recursive process.nextTick calls starving the event loop
function recurse() {
process.nextTick(recurse); // ❌ NEVER lets the loop proceed past nextTick draining
}
recurse();Since nextTick fully drains, including callbacks added during its own draining, before anything else runs, a nextTick callback that keeps re-scheduling itself can starve I/O, timers, and everything else indefinitely.
Best Practices
- Use
setImmediatefor "run this after I/O, but don't starve the loop", it's designed for exactly that, and won't recursively block other phases the way runawaynextTickrecursion can. - Reserve
process.nextTickfor genuinely urgent, short deferrals (like ensuring an error is emitted asynchronously to allow listener attachment first), not as a general-purpose scheduling tool, given its loop-starvation risk. - Don't rely on
setImmediatevs.setTimeout(fn, 0)ordering at the top level, if determinism matters, schedule from inside an I/O callback, or use a different explicit ordering mechanism.
Performance Tips
- Excessive
process.nextTickrecursion is a real, production-observed cause of "the event loop appears frozen" bugs, it doesn't block the call stack in the traditional sense, but it does prevent the loop from ever reaching timers/poll/check, so I/O appears to hang indefinitely. - Long synchronous work inside any single phase's callback (like the poll phase's I/O callback) still blocks the whole loop for that duration, libuv's phase structure doesn't parallelize your JS callbacks, it only structures when they run relative to I/O and timers.
