Kodokon kodokon.com

The Node event loop: phases and pitfalls

Discover the libuv phases, the real difference between setImmediate and setTimeout, and how to detect a blocked event loop.

10 min · 3 questions

Open this lesson in Kodokon

Node.js delegates its event loop to libuv, which organizes it into distinct phases: timers (setTimeout, setInterval), pending callbacks, idle/prepare (internal), poll (I/O: sockets, files), check (setImmediate), and close callbacks. On each tick, the loop walks through these phases in that order. A detail few developers know: between each callback, Node first drains the process.nextTick queue, then the promise microtasks - and this has been the case since Node 11, after every callback rather than only between phases.

JAVASCRIPT
import { readFile } from "node:fs";

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));

readFile(new URL(import.meta.url), () => {
  setTimeout(() => console.log("io timeout"), 0);
  setImmediate(() => console.log("io immediate"));
});
The order is only guaranteed inside an I/O callback.

When the script starts, the order between setTimeout(fn, 0) and setImmediate(fn) is non-deterministic: setTimeout(fn, 0) is actually clamped to 1 ms, and if the loop reaches the timers phase in less than a millisecond, the timer isn't eligible yet. Inside an I/O callback, however, you're in the poll phase: the check phase (setImmediate) always comes before returning to the timers phase. So io immediate is consistently printed before io timeout.

JAVASCRIPT
setTimeout(() => {
  process.nextTick(() => console.log("nextTick"));
  Promise.resolve().then(() => console.log("promise"));
  queueMicrotask(() => console.log("microtask"));
  console.log("sync");
}, 0);
// sync, nextTick, promise, microtask
Inside a callback, nextTick runs ahead of microtasks.

A subtle pitfall: at the top level of an ES module, this order is reversed - promise microtasks run before process.nextTick. The reason: evaluating an ES module is itself executed as a promise job, so the microtask queue is already being drained when your synchronous code finishes. In CommonJS, nextTick keeps its priority even at the top level. Always verify this kind of ordering in the module format you actually deploy.

The event loop runs on a single thread: any long synchronous computation - JSON.parse of a large payload, a catastrophic regular expression, synchronous encryption - freezes all in-flight requests. In production, measure loop latency with monitorEventLoopDelay: a p99 above a few tens of milliseconds signals a stall. The following code deliberately triggers a stall and then measures it.

JAVASCRIPT
import { monitorEventLoopDelay }
  from "node:perf_hooks";

const h = monitorEventLoopDelay({ resolution: 10 });
h.enable();

const end = Date.now() + 200;
while (Date.now() < end) {}

setTimeout(() => {
  h.disable();
  const ms = h.max / 1e6;
  console.log(`max delay: ${ms.toFixed(1)} ms`);
}, 300);
The histogram reveals the 200 ms stall.

Knowledge check

Make sure you remember the key points of this lesson.

  1. Inside an I/O callback (for example fs.readFile), you schedule setTimeout(fn, 0) and setImmediate(fn). Which runs first?
    • setTimeout, always
    • setImmediate, always
    • The order is random, as it is at script startup
  2. After a callback, in what order does Node drain the priority queues?
    • Promise microtasks go first
    • Both queues are drained in strict alternation
    • The process.nextTick queue is drained first, then microtasks
  3. Which native tool precisely measures event loop stalls in production?
    • console.time around each request
    • monitorEventLoopDelay from node:perf_hooks
    • process.memoryUsage called inside a setInterval
    • the node:trace_events module