CodeOath
← All posts
Node.js36 min total · 14 parts

Node.js Fundamentals: The Runtime, the Event Loop, and Building Real APIs

Part 3 of 14 · ~4 min

The Event Loop in Node

If you've read through the JavaScript closures and event-loop reference, you already have the core mental model: one call stack, a microtask queue (Promise .then callbacks, queueMicrotask) that drains completely before anything else gets a turn, and a macrotask layer (timers, I/O callbacks) that runs one task at a time between those full microtask drains. Node uses that same fundamental split, but — because there's no browser wrapping it, no rendering to coordinate with, and a much richer set of I/O sources — its macrotask layer is organized into distinct, ordered phases, and it adds a microtask-like queue of its own that the browser doesn't have at all: process.nextTick.

The loop's phases

Each pass through Node's event loop moves through a fixed sequence of phases, each with its own callback queue:

PhaseWhat runs here
timersCallbacks scheduled by setTimeout and setInterval whose delay has elapsed
pending callbacksCertain system-level callbacks deferred from the previous loop iteration (some TCP errors, for example)
pollRetrieves new I/O events; executes I/O callbacks (file reads finishing, incoming socket data, and so on). Node will block here waiting for new events if nothing else is scheduled
checkCallbacks scheduled with setImmediate
close callbacksCleanup callbacks, like a socket's 'close' event handler

The loop cycles through these phases in order, over and over, for as long as the process has pending work. The poll phase is where Node spends most of its time in a typical I/O-bound app — it's where completed I/O actually gets delivered to your callbacks, and where the loop will happily sit idle, without spinning your CPU, if there's genuinely nothing to do yet.

Where process.nextTick and Promises fit — precisely

This is the single most commonly misunderstood piece of Node's event loop, and it comes up constantly in interviews, so it's worth being exact about it: process.nextTick and the Promise microtask queue are not phases of the loop at all. They run between every single callback, not just between phases — after any callback finishes, and before the loop is allowed to move on to whatever's next (the next callback in the current phase, or the next phase entirely), Node drains the nextTick queue completely, then drains the Promise microtask queue completely, and only then continues. And nextTick always goes first — if a nextTick callback schedules another nextTick, that one runs too, before a single microtask (Promise callback) gets a turn.

console.log("start");

setTimeout(() => console.log("timeout"), 0);       // timers phase
setImmediate(() => console.log("immediate"));       // check phase
process.nextTick(() => console.log("nextTick"));    // drained before the next phase transition
Promise.resolve().then(() => console.log("promise")); // drained right after nextTick

console.log("end");

// Output: start, end, nextTick, promise, timeout, immediate
// (timeout vs. immediate order at the top level isn't fully guaranteed —
// see the callout below)

Walk through it: "start" and "end" run synchronously first, as always. Before the loop even enters its first phase, Node drains nextTick ("nextTick") and then Promise microtasks ("promise") completely. Only after both queues are empty does the loop move into the timers phase and run the setTimeout callback, then the check phase for setImmediate.

setTimeout(fn, 0) vs. setImmediate: the ambiguous case

At the top level of a script, the relative order of a zero-delay setTimeout and a setImmediate is not guaranteed — it depends on process startup timing and how long it takes the event loop to get moving, and you'll see it flip between runs. But inside an I/O callback, the order is deterministic and always the same: setImmediate always fires before a setTimeout(fn, 0), because an I/O callback runs during the poll phase, and check (where setImmediate lives) is the very next phase in line — timers won't get checked again until the loop comes back around.

const fs = require("fs");

fs.readFile(__filename, () => {
  setTimeout(() => console.log("timeout"), 0);
  setImmediate(() => console.log("immediate"));
});
// Always: immediate, timeout — because this runs inside the poll phase,
// and check (setImmediate) comes right after poll, before the loop
// cycles back around to timers

Common mistake: treating process.nextTick as "basically the same as a Promise .then(), just Node-specific." They're both microtask-like in that they run before the next macrotask, but nextTick always drains first, and — worse — a nextTick callback that keeps scheduling more nextTick calls will starve the event loop just as completely as the microtask-starvation example does in the browser, except it also blocks Promises from ever getting a turn, since nextTick is checked and drained before the microtask queue on every single pass.