Skip to main content
CodeOath
← All posts

Node.js95 min total · 14 parts

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

Part 3 of 14 · ~5 min

The Event Loop in Node

If the JS closures and event-loop reference is fresh in your head, you already have the browser half of this model for free: one call stack, a microtask queue drained down to zero before control moves anywhere else, and beneath it a macrotask tier that only ever lets a single task through between those full drains. Node borrows that same two-tier shape, but two things about running outside a browser change what sits on top of it: nothing here needs to leave room for a paint, and the catalogue of I/O sources feeding the loop is much bigger than "clicks, timers, fetches." So the macrotask tier isn't one queue — it's split into a fixed sequence of phases, and Node layers in a queue with no browser counterpart whatsoever: process.nextTick.

The phases, in the order they actually run

Each full lap of Node's event loop steps through a fixed sequence, each phase with its own queue of callbacks:

PhaseWhat runs here
timerssetTimeout/setInterval callbacks whose delay has elapsed
pending callbacksA small set of system callbacks deferred from the previous lap (some TCP-level errors, for instance)
pollDelivers new I/O — finished reads, incoming socket data — and runs those callbacks. If nothing else is queued, the loop parks here waiting for the next one
checksetImmediate callbacks
close callbacksCleanup handlers, like a socket's 'close' listener

For as long as billhook has anything pending, the loop just keeps stepping through this list, lap after lap. In an ordinary day, almost every lap is dominated by poll — it's the phase where finished disk writes and inbound socket bytes actually surface as callbacks, and when there's truly nothing for it to hand off yet, it simply waits there, not spinning the CPU for no reason.

Exactly where process.nextTick and Promises fit in

Here's the detail Ines actually needed during the eleven-second incident's post-mortem, once she started digging through billhook's cache-cleanup logic: process.nextTick and the Promise microtask queue don't belong to any phase — neither one. The instant one callback finishes, and before the loop takes so much as a step toward whatever's queued up after it — be that a sibling callback still sitting in this same phase, or a fresh phase starting up — Node empties the nextTick queue down to nothing, then empties the microtask queue down to nothing, and only after both are genuinely empty does anything else get to run. nextTick wins that race every time: if a nextTick callback turns around and queues a second nextTick, that second one also gets to run to completion before a lone microtask is allowed near the front of the line.

billhook schedules a nextTick to sweep a stale entry out of its idempotency cache right after saving each webhook, and separately resolves a Promise once the database write confirms. Trimmed to the ordering that matters:

console.log("delivery received");

setTimeout(() => console.log("retry-timeout check"), 0);      // timers phase
setImmediate(() => console.log("flush access log"));           // check phase
process.nextTick(() => console.log("evict stale cache entry")); // drained before the next phase change
Promise.resolve().then(() => console.log("db write confirmed")); // drained right after nextTick

console.log("handler returned");

// Output: delivery received, handler returned, evict stale cache entry,
//         db write confirmed, retry-timeout check, flush access log

(That ordering came straight out of a real Node 24 run, not a guess.) "delivery received" and "handler returned" are plain synchronous code — no different from a script with zero async anywhere in it, so they print first, in order. What happens next is the interesting part: the loop hasn't touched a single phase yet, and it won't until both queues are dry — nextTick first (the cache eviction), microtasks second (the confirmed write). Only once neither queue has anything left does timers get its turn for the retry check, followed by check for the log flush.

There's a sharper version of this rule that matters once billhook is handling more than one delivery per phase: the draining happens between every individual callback, not just at phase boundaries. Queue two setImmediate callbacks — one flushing delivery A's log, one flushing delivery B's — and have the first one schedule a nextTick:

setImmediate(() => {
  console.log("flush A's log");
  process.nextTick(() => console.log("A's nextTick"));
});
setImmediate(() => console.log("flush B's log"));

// Output: flush A's log, A's nextTick, flush B's log

A's nextTick runs before B's setImmediate even though both are sitting in the same check phase's queue — draining happens after each callback finishes, so a nextTick scheduled inside one same-phase callback jumps ahead of its neighbor rather than waiting for the whole phase to clear first.

setTimeout(fn, 0) vs. setImmediate, deterministically

Right at a script's top level, a same-tick race between a zero-delay setTimeout and a setImmediate has no fixed winner — whichever way process startup happens to land that particular run decides it, and it genuinely varies run to run. From inside an I/O callback, that uncertainty disappears completely. setImmediate wins, unconditionally, every single time — an I/O callback is running during poll, and the very next stop on the loop's route is check, home to setImmediate; timers doesn't come back into view again until an entire lap later.

const fs = require("fs");

fs.writeFile("./receipts/ord_4471.pdf", receiptBuffer, () => {
  setTimeout(() => console.log("retry-timeout check"), 0);
  setImmediate(() => console.log("flush access log"));
});
// Always: flush access log, retry-timeout check — this callback runs
// inside poll, and check comes immediately after poll in the cycle

Also verified directly — billhook's own reconciliation job, which fires after a receipt write finishes, used to assume setTimeout(fn, 0) would run first. It never does, from inside an I/O callback, and that assumption produced a genuinely confusing off-by-one-tick bug in an early build before Devon traced it back to exactly this.

Common mistake: assuming process.nextTick is just Node's spelling for a Promise .then(). Both jump the queue ahead of the next macrotask, sure, but nextTick is first among those two, not equal to the other — and a nextTick handler that keeps requeuing itself locks the loop up even more thoroughly than a runaway .then() chain would, because it holds up the microtask queue too. Every single pass checks nextTick before it so much as looks at a pending Promise, not merely the first one.