The browser's event loop (macrotasks vs. microtasks) is only half the story in Node.js — libuv adds six distinct phases, and process.nextTick sits in its own queue with HIGHER priority than promises, draining completely between every single phase transition. This flagship topic walks all of it step by step.
1. How many distinct phases does libuv's event loop cycle through per iteration?
Interview Questions
2 questions
Cheatsheet
Download-ready reference
Cheatsheet
libuv's SIX phases per iteration (in order):
1. timers → setTimeout/setInterval callbacks whose time elapsed
2. pending callbacks → deferred I/O callbacks from prior iteration (rare)
3. poll → new I/O events + their callbacks; CAN BLOCK here
4. check → setImmediate callbacks (right after poll)
5. close callbacks → e.g. socket.on('close', ...)
6. → back to timers
PRIORITY (strict): process.nextTick > Promise microtasks > libuv phases
Both nextTick AND microtask queues drain FULLY between EVERY
phase transition — not just once per full loop iteration.
process.nextTick: drains to complete emptiness, INCLUDING callbacks
added to it during its own draining.
⚠️ Risk: recursive nextTick can starve the ENTIRE loop (I/O/timers
never run) — prefer setImmediate for "after I/O, don't starve" needs.
setImmediate vs setTimeout(fn, 0):
Inside an I/O callback (already in poll) → setImmediate ALWAYS wins
(check is the very next phase, same iteration)
At the TOP LEVEL → NOT guaranteed (depends on startup timing)
Long sync work in ANY phase's callback still blocks the WHOLE loop —
phases organize timing, they don't parallelize your JS.
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.
timers — runs callbacks scheduled by setTimeout/setInterval whose 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 setImmediate callbacks, 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"
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.
setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));// which logs first? Depends on process startup timing — NOT guaranteed
Top-level: non-deterministic. Inside an I/O callback: deterministic.
step 1 / 3
// Called from the TOP LEVEL (main module)
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
libuv phases (one iteration)
timers
() => console.log('timeout')
pending callbacks
poll
check
() => console.log('immediate')
close callbacks
process.nextTick queue
(empty)
Microtask queue
(empty)
Currently executing
(nothing running)
Console output
⚠️ order NOT guaranteed — depends on process startup timing
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.
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.
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
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.
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.
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 once
The browser's two-queue mental model is incomplete for Node — the three-tier priority (nextTick > microtasks > libuv phases) is Node-specific.
setTimeout(() => console.log("timeout"), 0);setImmediate(() => console.log("immediate"));// NOT guaranteed at the top level — only guaranteed inside an I/O callback
This 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.
Use setImmediate for "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 runaway nextTick recursion can.
Reserve process.nextTick for 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 setImmediate vs. 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.
Excessive process.nextTick recursion 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.
),
0
);
setImmediate(() => console.log("immediate from poll"));