One thread, six queues, and two that jump the line
Parts 5, 8 and 9 all end in the same place. A worker finishes a read, a packet arrives, a parser completes a message — and then the loop runs your callback. Every one of those traces stops at the door of the same room. This part goes inside it first.
An iteration of the event loop is not magic and it is not concurrent. It is a function that walks six phases in a fixed order, drains a queue at each one, and blocks in exactly one of them. Everything you have ever scheduled lands in one of those six queues — except for two things, which are not phases at all and cut in front of all of them.
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// from the main module -> the order changes between runs
// from inside an I/O cb -> "immediate" wins, every single timeBoth of those facts fall out of one mechanism, and neither is a special case. The loop always runs its phases in the same order. What changes is where in that order you were standing when you scheduled.
An iteration of the event loop is ordinary machine code running on the main JS thread. Nothing about it is parallel. The only reason a Node server feels concurrent is that all of the waiting happens inside one syscall.
A while loop with a queue at every stop
Strip away the vocabulary and the loop is a while loop in C. It asks whether anything is left to wait for; if so it walks the phases, running whatever callbacks each phase owns. Then it goes round again.
while (uv__loop_alive(loop) && !loop->stop_flag) {
uv__update_time(loop); // one clock read per iteration
uv__run_timers(loop); // phase 1
uv__run_pending(loop); // phase 2
uv__run_idle(loop); // phase 3
uv__run_prepare(loop);
timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout); // phase 4 - BLOCKS HERE
uv__run_check(loop); // phase 5
uv__run_closing_handles(loop); // phase 6
}Two details there matter more than they look. uv__update_time reads the clock once per iteration — every timer checked during this trip is compared against the same "now", not against a fresh reading. And uv_backend_timeout computes how long the loop may sleep, which is the hinge the rest of this part turns on.
The loop runs while it has handles or requests outstanding. When the last one goes away uv__loop_alive returns false, the while loop ends, and your process exits on its own. Nobody calls exit.
Six queues in a fixed order
Each phase owns a queue and drains it before the next begins. The order never varies and the loop never skips a phase — a phase with an empty queue still happens, it just costs a comparison.
setImmediate is predictable from inside an I/O callback and setTimeout is not.socket.on('close') and friends — after the handle itself has been torn down.Notice what is missing from that list. There is no phase for promises and no phase for process.nextTick. Those two are handled differently, and the difference is the next panel.
nextTick and microtasks cut the line
process.nextTick and resolved promise continuations do not live in the loop. They drain between callbacks — after the current one returns and before the loop takes another step, including between two callbacks of the same phase.
setTimeout(() => console.log("4 · timers phase"), 0);
setImmediate(() => console.log("5 · check phase"));
Promise.resolve().then(() => console.log("2 · microtask"));
process.nextTick(() => console.log("1 · nextTick"));
console.log("0 · synchronous");
// 0 · synchronous
// 1 · nextTick <- both drain before the loop
// 2 · microtask takes a single step
// 4 · timers phase <- and only then does the loop start
// 5 · check phaseBecause both queues drain to exhaustion, and both can feed themselves while draining, either one can stop the loop from advancing again. That is not a bug — it is the direct consequence of the words "to exhaustion".
The delay is a floor, never a promise
setTimeout(fn, 100) guarantees that at least 100ms will pass before fn runs. It guarantees nothing about the upper bound, because the callback can only run when the loop next reaches the timers phase — and the loop only gets there when whatever is running now gives control back.
setTimeout(() => console.log("100ms?"), 100);
const until = Date.now() + 500;
while (Date.now() < until) {} // 500ms of synchronous work
// prints at ~500ms - and nothing is brokenNothing there is late in the sense of a missed deadline. The timer became due at 100ms and ran at the first moment the loop was allowed to look at it. Node did exactly what it promised; the promise is just weaker than most people read it as.
The same reasoning covers a smaller surprise: setTimeout(fn, 0) is clamped to 1ms internally. There is no zero-delay timer, and even if there were it would not run sooner — it would still wait for the timers phase.
A list per duration, and one libuv timer per list
The obvious implementation — one OS timer per setTimeout — would be ruinous for a server holding thousands of idle connection timeouts. Node does something much cheaper, in two layers.
setTimeout(fn, 5000) in your program goes into the same 5000ms list. Timers of equal duration are inherently sorted by insertion order, so appending is O(1) and nothing is ever sorted.This is why a process with tens of thousands of pending timers shows no measurable CPU cost for them. Nothing polls them and nothing iterates them. The loop asks one question of one heap root, once per iteration.
One number decides how long the process sleeps
The timers phase and the poll phase are not competing systems, and this is where that stops being a claim and becomes a mechanism. Before the loop blocks it asks uv_backend_timeout for a number, and that number is how long epoll_wait may sleep.
Read the setImmediate case carefully. When one is pending the timeout is zero — the loop refuses to sleep at all, because it has work waiting in the check phase directly after poll. That single rule is most of what people find mysterious about setImmediate.
There is no timer thread and no polling. A deadline and a socket are the same kind of event to this loop, because a deadline is expressed as the timeout argument of the syscall it was already going to make.
The interval starts when the callback ends
setInterval(fn, 1000) does not fire on a fixed wall-clock cadence. Node re-arms the timer after your callback returns, so the real period is your interval plus however long the callback took, plus however long the loop took to reach the timers phase.
let n = 0;
const started = Date.now();
setInterval(() => {
n++;
block(50); // 50ms of work in the callback
console.log(n, Date.now() - started); // 1 1050 · 2 2100 · 3 3150 ...
}, 1000);Fifty milliseconds a tick sounds harmless until you notice it is never paid back. After an hour that clock is three minutes slow and drifting further every tick. If you need a cadence rather than a delay, compute the next deadline from a fixed origin and use setTimeout to hit it — do not accumulate.
An interval is a delay that re-arms, not a schedule. If the callback takes longer than the interval the fires do not queue up and they do not overlap — they simply happen less often than you asked, and the drift never recovers.
The answer depends on where you were standing
From the main module the order of these two is genuinely unstable — run it repeatedly and it will change. From inside an I/O callback it is completely deterministic and setImmediate always wins. Same two lines, two different answers.
// main module - unstable
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// inside an I/O callback - immediate ALWAYS first
fs.readFile(__filename, () => {
setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
});From the main module the loop has not started yet. By the time it reaches the timers phase, either the 1ms clamp has elapsed or it has not — and which of those is true depends on how long process startup happened to take. Microseconds decide it, which is why the order is unstable rather than merely undocumented.
Inside an I/O callback there is nothing to decide. An I/O callback runs in the poll phase; the very next phase is check. The timers phase is a whole iteration away.
nextTick drains before the microtask queue
Both queues jump the loop, so between them there has to be a rule. In Node the nextTick queue drains completely first and only then the microtask queue — and if a nextTick callback schedules a promise, or a promise callback schedules a nextTick, the cycle repeats until both are empty.
process.nextTick(() => {
console.log("A · nextTick");
Promise.resolve().then(() => console.log("C · promise from nextTick"));
});
Promise.resolve().then(() => console.log("B · promise"));
process.nextTick(() => console.log("A2 · nextTick"));
// A · nextTick
// A2 · nextTick <- the whole nextTick queue first
// B · promise
// C · promise from nextTick <- added during the drain, still runs nowThis ordering is a Node decision, not part of the language. A browser has no process.nextTick at all and the microtask queue is the only jumper. Code that relies on nextTick beating promises is code that will not port.
If you reach for process.nextTick to fix an ordering problem, the ordering problem is usually the real bug. The queue exists so Node can let you attach handlers before an event fires — not as a general-purpose "run this a bit sooner".
One recursive nextTick and nothing else ever runs
Both jump-the-queue queues drain to exhaustion and both can feed themselves while draining. A process.nextTick that schedules another one therefore never lets the loop advance a single phase.
setTimeout(() => console.log("never printed"), 0);
const server = net.createServer(handler); // never accepts anything
function spin() { process.nextTick(spin); }
spin();
// 100% CPU · no timers · no I/O · no close handlers · foreverNothing throws and nothing warns. The process is at full CPU doing real work and every other queue in the system is frozen behind it. A health check against that server does not fail slowly — it is never answered at all, because the accept queue (Part 8) is filling up behind a thread that will never call accept() again.
The same shape with setImmediate is harmless by comparison: it schedules into the check phase, so each recursion costs one full iteration and timers and I/O still get their turn. That difference — starve versus yield — is the practical reason to reach for setImmediate when you want to break work up.
A long synchronous function does it just as well
Starvation gets attention because it looks exotic. The ordinary version is far more common and does exactly the same damage: a synchronous function that takes a long time.
Every part of this series has now landed on the same sentence from a different direction. There is one thread that runs your JavaScript. Whatever holds it — a queue that refills, a function that will not return, or a syscall that will not come back — stops everything else in the process.
The loop is alive while it has handles
A Node program does not end when the last line of your file runs. It ends when the loop has nothing left to wait for — and "nothing left" has a precise meaning: no active handles and no active requests.
listen() returns.uv_fs_t is one of these while the worker is reading.setInterval holds a process open forever, which is the commonest cause of a script that will not exit.beforeExit fires, and the process exits with code 0. Nobody called process.exit().const t = setInterval(() => {}, 1000); // holds the process open forever
// clearInterval(t) <- or t.unref(), see the next panelKeep working; just do not be the reason we stay up
unref() does not stop a handle from working. It removes the handle from the aliveness count, which says: carry on doing your job, but do not be the reason this process stays alive.
const beat = setInterval(() => metrics.flush(), 30_000); beat.unref(); // the heartbeat still fires while the server is running, // and the process still exits cleanly when the server closes
That is exactly the right tool for a background heartbeat, a metrics flush or a cache sweep: work that should happen while the program is alive and should never be what keeps it alive. It is also the fix for the interval in the previous panel.
The mirror image is ref(), and the pair matters in tests more than anywhere else — a test runner that hangs after the last assertion is almost always one un-unrefed handle that nobody closed.
Not an id — a handle with methods
In a browser setTimeout returns a number. In Node it returns a Timeout object, and the difference is useful.
clearTimeout accepts either and why browser-shaped code keeps working unchanged.One iteration, with both jumpers drawn in
Put the six phases and the two queues on one page and the ordering questions stop being puzzles. Every scheduling primitive in Node has exactly one home in this diagram.
Nine claims, and the mechanism behind each
These are the beliefs that produce the bugs. Each is a reasonable reading of the API surface, and each is wrong for the same underlying reason.
Every wrong take here assumes one of three things that is not there: a clock that interrupts you, a thread that runs your callback while you are busy, or a queue with a budget. Take those three away and the rest of the model follows.
If you remember nothing else
The loop walks six phases in a fixed order and blocks in exactly one of them. That is the whole architecture; everything else is detail about which queue a particular function writes to.
Part 10 takes the last of those seriously and asks the obvious next question: if one thread is the constraint, what happens when you ask for another one? The answer is not the thread you are expecting.