Appearance
3.8.1 — Node.js: Architecture & the Event Loop's Phases
JavaScript was designed to run in a browser. In 2009 Ryan Dahl took Google's V8 engine (3.6.9), bolted on the ability to touch files, networks, and the operating system, and created Node.js — putting JavaScript on the server. His motivation explains Node's entire design: he had watched web servers waste enormous resources on the thread-per-connection model, where thousands of threads sat blocked doing nothing but waiting on I/O (2.7's C10k problem). JavaScript, with its single-threaded event loop and callback culture, was accidentally the perfect language for the alternative.
This folder opens Node completely — architecture, thread pool, buffers, streams, events, scaling, and the module zoo. This first page covers what Node is as a piece of software and the event loop's phases: the Node-specific refinement of 3.6.8's universal loop, home of the setTimeout-vs-setImmediate-vs-nextTick questions that appear in nearly every Node interview.
1. What Node.js actually is
Node is not a language and not a framework. It is a runtime: a program that embeds the V8 JavaScript engine and surrounds it with capabilities V8 alone doesn't have. V8 can evaluate JavaScript, but it knows nothing of files, sockets, or processes — in a browser those come from Web APIs; on a server they must come from somewhere else. Node supplies them.
Its architecture has three layers:
- V8 — parses, compiles, and executes your JavaScript (3.6.9: Ignition, TurboFan, Orinoco GC).
- libuv — a C library providing the event loop, asynchronous I/O across platforms, and a thread pool (3.8.2). This is where Node's concurrency actually lives.
- Node's own bindings and standard library — the C++ glue exposing operating-system capabilities to JavaScript, plus the built-in modules (
fs,http,crypto, …).
So when your code calls fs.readFile, the path is: JavaScript → Node's fs module → C++ binding → libuv → the operating system (2.1's syscall) → and back, the result delivered as a callback via the event loop. Everything in 3.6.8 applies here — Node simply supplies a different set of "platform APIs" than a browser does. The truth about "single-threaded" — your JavaScript is; Node is not — and exactly which work runs on which threads is the next page's subject (3.8.2).
2. The event loop's phases
3.6.8 gave the universal rule — drain all microtasks, then take a task. Node's libuv loop refines "task" into phases, each with its own queue, processed in fixed order every iteration (a "tick"):
process.nextTick first, then promises.- timers — callbacks for
setTimeout/setIntervalwhose time has elapsed. - pending callbacks — certain deferred system callbacks (e.g. some TCP errors).
- idle/prepare — internal use only (omitted from the figure).
- poll — the heart: retrieve new I/O events and run their callbacks (incoming requests, completed file reads). If nothing is pending, the loop may block here waiting for I/O — exactly the efficient sleeping of 2.7's
epoll_wait. - check — callbacks scheduled by
setImmediate. - close callbacks — e.g. a socket's
'close'event.
Between every phase (and between individual callbacks), Node drains the microtask queues — and Node has two, in priority order: first process.nextTick()'s queue, then the promise microtask queue. Full priority: process.nextTick → promises → the current phase's callbacks.
One honest detail about where timers run. The numbered list is the classic description, and it is still the right mental model. Since libuv 1.45, which shipped with Node.js 20, libuv checks for expired timers after the poll phase in each iteration rather than before it, and runs them once before the loop starts so older code keeps working. The Node.js documentation notes that this "can affect the timing of setImmediate() callbacks and how they interact with timers in certain scenarios." In practice the two rules in the next section still hold on current versions, and you can check them yourself: both were re-run for this page on Node.js 22.23 (libuv 1.51). What the change means for you is the same lesson as section 4: never let correctness depend on the exact phase a timer lands in.
As of October 2026: which Node.js to run
- Node.js 24 ("Krypton") is the Active LTS line until 20 October 2026, then moves to maintenance until 30 April 2028.
- Node.js 26 was released in May 2026 and becomes the new Active LTS on 28 October 2026; supported until 30 April 2029.
- Node.js 22 ("Jod") is in maintenance (security and critical fixes only) until 30 April 2027. The
node:22images used in Part 13's Docker examples are still supported, but start new projects on 24, or on 26 once it is LTS. - Node.js 20 reached end of life on 30 April 2026 and gets no more security fixes. Upgrade anything still on it.
Even-numbered majors become LTS each October; odd-numbered majors are short-lived. Check the official schedule at github.com/nodejs/Release before pinning a version, because these dates are the part of this page that will go stale.
3. The classic ordering questions, settled
setTimeout(fn, 0) vs setImmediate(fn). setTimeout runs in the timers phase; setImmediate in the check phase. From the main module, their relative order is genuinely non-deterministic — it depends on whether the ~1 ms timer threshold elapsed during process startup, so the order can flip between runs. Measured for this page: running a two-line script with both calls forty times on Node.js 22.23 printed immediate first 36 times and timeout first 4 times. But inside an I/O callback (the poll phase), the order is guaranteed: setImmediate always fires first, because check comes immediately after poll, whereas timers wait for the next full iteration. ⚑Difference between setTimeout(fn, 0) and setImmediate()? [EQ-21]
process.nextTick() vs setImmediate(). Despite the names suggesting the opposite, process.nextTick fires sooner: it isn't a phase at all but a microtask-like queue drained between operations, before promises and before the loop advances. setImmediate is a proper phase callback. So nextTick runs before everything. That is powerful, and dangerous. A nextTick that keeps scheduling itself starves the loop completely. The loop never reaches the poll phase, so no I/O is ever handled. Use setImmediate to give the loop a turn. Use nextTick rarely, for "run this right after the current operation, before anything else". Its proper use is making an API reliably asynchronous, for example sending an event only after the caller has had a chance to attach its listeners. ⚑What is the difference between process.nextTick() and setImmediate()? [EQ-20]⚑Explain the phases of the Event Loop. [EQ-8]
One fully-worked trace to lock the model — inside an I/O callback:
javascript
fs.readFile("data.txt", () => { // we are in the POLL phase
setTimeout(() => console.log("timeout"), 0); // → timers, NEXT iteration
setImmediate(() => console.log("immediate")); // → check, THIS iteration
process.nextTick(() => console.log("nextTick")); // → before anything advances
Promise.resolve().then(() => console.log("promise")); // → after nextTick
console.log("sync");
});
// Output, guaranteed: sync → nextTick → promise → immediate → timeoutRun on Node.js 22.23, the script prints exactly sync nextTick promise immediate timeout, every time. Read it off the machinery: synchronous code finishes; the nextTick queue drains; the promise queue drains; the loop advances from poll to check (immediate); timeout waits for the next tick's timers phase.
4. The expert lens
Node's architecture is a single bet: optimize for waiting, not computing. Every design choice follows — the event loop, non-blocking I/O, streams, the callback/promise culture. It's excellent for the majority of backend work (APIs, gateways, proxies, real-time services), where the process spends its life waiting on databases and networks, and it lets one process handle connection counts that would need thousands of threads elsewhere (2.7). It's a poor fit for sustained CPU-heavy work unless you deliberately move that work off the loop (3.8.6). "Is Node fast?" — honestly: extremely efficient at concurrency-under-waiting, unremarkable at raw computation; knowing which your workload is decides whether it's the right tool.
Phases are the debugging map, not trivia. "Why does my timer fire late?" (a long poll callback or microtask storm delayed the timers phase), "why does this setImmediate beat my setTimeout?" (you're in an I/O callback), "why did my recursive queue-drainer freeze the server?" (nextTick starvation) — each everyday mystery resolves by placing the callback in Figure 2. When ordering matters and you find yourself unsure, don't guess and don't cargo-cult — trace the tick as in section 3's example. And when ordering matters architecturally, prefer explicit sequencing (promises, queues) over phase arcana: code whose correctness depends on setImmediate-vs-setTimeout subtleties is fragile by construction.
Next: the threads behind the curtain — 3.8.2: what's genuinely non-blocking, what secretly runs on 4 threads, and the production symptom that confusion causes.
Recall
- Node.js is a runtime: V8 (executes JavaScript) + libuv (event loop, async I/O, thread pool) + C++ bindings and standard library. Born to escape thread-per-connection waste (the C10k problem).
fs.readFile= JS → binding → libuv → syscall → callback via the loop. - Phases per tick: timers → pending callbacks → poll (I/O heart; may sleep) → check (
setImmediate) → close. Between every phase and callback:process.nextTickqueue, then promises — Node has two microtask queues. setTimeout(0)vssetImmediate: non-deterministic from the main module, butsetImmediatealways wins inside an I/O callback (check follows poll). process.nextTick runs before everything — recursion starves the loop; its niche is deferring emission until listeners attach. Guaranteed I/O-callback order: sync → nextTick → promise → immediate → next-tick timers.- The bet: optimize for waiting, not computing — superb for I/O-bound services, wrong for sustained CPU unless offloaded (3.8.6).
Self-test: Name Node's three layers and each one's job. Walk the phases of one tick and say where the process sleeps. Why is setTimeout(0) vs setImmediate non-deterministic at startup but deterministic in an I/O callback? Reproduce section 3's five-line output from the machinery. What is nextTick's legitimate use, and its failure mode?
Quiz Bank
FoundationalWhat is Node.js, architecturally?
Node.js is a runtime — not a language or framework — that embeds Google's V8 engine and surrounds it with capabilities V8 lacks. Three layers: V8 parses/compiles/executes JavaScript (3.6.9); libuv, a C library, provides the event loop, cross-platform asynchronous I/O, and the thread pool; and Node's C++ bindings + standard library expose OS capabilities (files, sockets, processes) to JavaScript. fs.readFile travels JavaScript → fs module → C++ binding → libuv → OS syscall (2.1) → back as a callback via the loop. Node was created to escape the thread-per-connection model, whose thousands of threads sat blocked waiting on I/O.
AppliedExplain the phases of Node's event loop.
Each turn of the loop runs a fixed list of phases, in order, and each phase has its own queue:
- timers: callbacks from
setTimeoutandsetIntervalwhose time is up. - Pending callbacks: some system callbacks that were put off.
- Idle and prepare: internal only.
- poll: the heart of the loop. Collect new I/O events and run their callbacks. If nothing is waiting, sleep here until I/O arrives, using the efficient wait from 2.7 (
epoll_wait). - check:
setImmediatecallbacks. - Close callbacks, such as a socket's
'close'.
Between every phase, and between single callbacks, Node empties its two microtask queues: process.nextTick first, then promises. So the overall priority is nextTick, then promises, then the current phase's callbacks.
InterviewWhat's the difference between setTimeout(fn, 0), setImmediate(fn), and process.nextTick(fn)?
Different homes. setTimeout(fn, 0) queues in the timers phase; setImmediate in the check phase (right after poll); process.nextTick isn't a phase — it's a microtask-style queue drained between operations, before promises and before the loop advances, so it runs soonest despite its name. Ordering: from the main module, setTimeout(0) vs setImmediate is non-deterministic (depends on whether the ~1 ms threshold elapsed during startup); inside an I/O callback, setImmediate is guaranteed first (check follows poll; timers wait a full iteration). Hazard: recursive nextTick starves the loop — no I/O ever runs; yield with setImmediate instead. Legitimate nextTick niche: keeping APIs consistently async — emit after the caller could attach listeners.
InterviewWhy is the poll phase called the heart of the loop, and what does it mean that Node sleeps there?
The poll phase is where Node meets the operating system: it retrieves completed I/O events (readable sockets, finished file reads via the pool — 3.8.2) and runs their callbacks — which in a server is nearly all the work. When no timers are due and no callbacks are queued, the loop doesn't spin — it blocks in the kernel (epoll_wait/kqueue/IOCP — 2.7) until an event arrives or the nearest timer's deadline approaches. That sleep is why an idle Node server consumes ~0% CPU while holding tens of thousands of open connections: the kernel does the waiting, and the process wakes only when there's actual work. It's the C10k answer in one mechanism — and it's also why blocking the loop is so harmful (3.8.2): while your code runs, nothing returns to poll, so all those ready events wait.
StaffA metrics daemon's setInterval(fn, 1000) drifts badly under load — ticks arrive late and sometimes bunch up. Explain from the loop's mechanics, and design timing that meets its guarantees honestly.
setInterval promises only "not before" — the callback becomes eligible in the timers phase, and the timers phase runs when the loop gets there. Under load, long poll-phase callbacks, microtask storms (each phase boundary drains them fully), or thread-pool-delayed I/O callbacks occupy the loop past the deadline; the tick fires late, and if multiple intervals elapse, callbacks bunch. Diagnose with perf_hooks.monitorEventLoopDelay() — loop lag is the drift, and it names the real culprit (blocked loop) rather than the symptom (late timer).
Design:
- Do not let the error add up. Schedule each run from when the next one should happen:
next += 1000; setTimeout(fn, next - Date.now()). Then a late tick corrects itself instead of pushing every later tick back. - Record the real time of each measurement with
Date.now()orhrtimewhen it runs. Never assume the tick was on time. Then any maths done later still works when ticks are late. - Fix the cause. Find whatever is blocking the loop and fix it, by splitting CPU work into chunks or moving it to workers (3.8.6).
- If exact timing matters more than convenience, move the ticker to a worker thread that has little else to do. Or accept how the platform works. An event loop only switches tasks when the current one lets go, and a scheduler like that can only promise best-effort timing (2.3).
Alert on loop lag, not on late metrics — it's the leading indicator.
Flashcards
FlashNode = ?
A runtime: V8 (runs JS) + libuv (event loop, async I/O, thread pool) + C++ bindings and standard library.
FlashEvent loop phases (Node)
timers → pending callbacks → idle/prepare → poll (I/O; may sleep) → check (setImmediate) → close callbacks. Microtasks drain between phases.
FlashNode's TWO microtask queues
process.nextTick queue first, then promises — both drained between every phase and callback.
FlashnextTick vs setImmediate
nextTick: between operations, before promises — soonest; recursion starves the loop. setImmediate: the check phase, right after poll.
FlashsetTimeout(0) vs setImmediate ordering
Non-deterministic from the main module; setImmediate guaranteed first inside an I/O callback (check follows poll).
FlashThe I/O-callback order
sync → nextTick → promises → setImmediate (check) → setTimeout (next tick's timers).
Scenario Drill
DrillA teammate's queue-drainer uses process.nextTick recursively to process items 'as fast as possible', and under a large batch the health-check endpoint stops responding entirely, though CPU shows one busy core. Explain the freeze mechanically and redesign the drainer.
The freeze is nextTick starvation, visible straight from the phase model. process.nextTick callbacks are drained completely, between operations, before the loop advances — and each drained callback that schedules another nextTick appends to the same queue currently being drained. A recursive drainer therefore never lets the loop reach the poll phase: incoming health-check requests sit as ready events the loop never retrieves, timers never fire, and the process appears hung while one core burns (the drain loop is pure JavaScript execution). It's the cooperative-scheduling failure in miniature: a task that never yields owns the machine (2.3).
Redesign:
- Switch the recursion to setImmediate, which runs in the check phase. After each item, schedule the next one with
setImmediate. Between items, the loop finishes its turn: it polls for I/O, answers health checks and fires timers. The batch still moves fast. This is the standard "yielding loop". - Better: do a chunk of work per turn. Process up to N items, or for up to T milliseconds, then
setImmediatethe rest. That cuts the scheduling overhead while still limiting how long the loop is busy. Tune N and T while watchingmonitorEventLoopDelay. - If each item is heavy on CPU, and not just numerous, chunks still hold the loop too long. Move the work to worker_threads (3.8.6), and keep the loop for I/O.
- Add an alert on loop lag, so the next time this happens it is a spike on a dashboard, not an outage.
Rule to leave the team: nextTick runs before the world and must never recurse; setImmediate is the polite "continue after the loop breathes" — pick by whether you intend to preempt the loop or yield to it.