The event loop phases and libuv

Node.js · Advanced · 18 min

Explains surprising callback orders, starved I/O, and why some 'async' operations are slower than others.

Node's event loop, provided by libuv, cycles through phases, each with a queue of callbacks:

  • timers — expired setTimeout / setInterval callbacks.
  • pending callbacks — some deferred system errors.
  • poll — waits for new I/O events and runs their callbacks (socket data, completed file reads). If nothing else is scheduled, the loop blocks here.
  • check — setImmediate callbacks.
  • close callbacks — 'close' events of handles such as sockets.

Between every individual callback, Node drains two higher-priority queues: first the `process.nextTick` queue, then the promise microtask queue (then callbacks, code after await). That is why a promise continuation always runs before the next timer, and why recursive nextTick (or an endless chain of resolved promises) can starve I/O completely.

Practical consequences:

  • Inside an I/O callback, setImmediate always runs before setTimeout(fn, 0), because check comes right after poll. From the main module their order is not guaranteed.
  • setTimeout(fn, 10) means "not before 10 ms", never "exactly at 10 ms".
  • Prefer queueMicrotask or setImmediate over process.nextTick unless you specifically need to run before any other async work.

Network I/O uses the OS's non-blocking mechanisms (epoll, kqueue, IOCP) directly. But file system calls, dns.lookup, and CPU-heavy crypto/zlib work run on libuv's thread pool, which has only 4 threads by default (UV_THREADPOOL_SIZE, max 1024, set before startup). Many concurrent fs or pbkdf2 calls queue behind each other there — a frequent hidden bottleneck.

Measure loop health with perf_hooks.monitorEventLoopDelay(); consistently high delay means something is blocking.

Example

import { readFile } from "node:fs";

readFile(import.meta.filename, () => {           // we are now in a poll-phase callback
  console.log("1 sync");
  setTimeout(() => console.log("6 timeout"), 0);
  setImmediate(() => console.log("5 immediate")); // check phase comes right after poll
  Promise.resolve().then(() => console.log("3 promise"));
  queueMicrotask(() => console.log("4 microtask"));
  process.nextTick(() => console.log("2 nextTick"));
});
// 1 sync, 2 nextTick, 3 promise, 4 microtask, 5 immediate, 6 timeout

// Gotcha: the body of an ES module itself runs inside a promise job, so at the
// top level of an .mjs file promise callbacks print BEFORE nextTick callbacks,
// and setTimeout(0) vs setImmediate order is not guaranteed there.

Remember

  • Phases: timers → pending → poll → check → close
  • nextTick then promise microtasks run between every callback
  • fs, dns.lookup, crypto and zlib use libuv's 4-thread pool by default
  • Starving the loop with microtasks blocks I/O

Watch out for

  • Using process.nextTick recursively and starving I/O.
  • Running hundreds of concurrent fs or crypto calls and blaming the disk for thread-pool queuing.

Check yourself

  1. Inside an fs.readFile callback, you schedule setTimeout(a, 0) and setImmediate(b). Which runs first?
  2. Your server makes many parallel crypto.pbkdf2 calls and latency climbs. Which setting is most relevant?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/asynchronous-work/event-loop-timers-and-nexttick