Ch. 6 · Node.js

The Node.js Event Loop Phases, Explained

What libuv does on each turn of the loop, where nextTick, promises, setImmediate and timers really run, and how to keep CPU-heavy work from blocking everything.

~7 min readadvanced

Node runs your JavaScript on a single thread, yet one process can juggle thousands of open sockets. The trick is the event loop, implemented by the C library libuv: JavaScript starts some work, libuv waits for the operating system (or a thread pool) to finish it, and the loop calls your callbacks back in a fixed order of phases. Knowing that order lets you predict tricky output and, more usefully, explain why a server stops responding when one request hogs the CPU. Every output below is real, captured on Node 22. For the browser’s model, see microtasks vs macrotasks.

libuv and the six phases

After your main script finishes, Node enters the loop. Each iteration visits these phases in order, and each has its own callback queue:

Phase What runs there
timers Expired setTimeout and setInterval callbacks
pending callbacks Some system callbacks deferred from the previous iteration, such as certain TCP errors
idle, prepare Internal housekeeping for Node and libuv
poll New I/O events and their callbacks: sockets, file reads, most of your code
check setImmediate callbacks
close callbacks 'close' handlers for abruptly closed handles, such as after socket.destroy()

Poll is where the loop parks when idle. If immediates are queued it doesn’t block; otherwise it waits for I/O, but only until the nearest timer is due. When no timers, sockets or pending requests remain, the loop exits and so does the process.

Note

If you read libuv’s uv_run source, the timers step actually sits at the end of each iteration, with one extra run just before the first. It’s the same cycle drawn from a different starting point, so the phase order above is still the right mental model.

nextTick and promise microtasks

process.nextTick and promises aren’t phases at all. They’re two queues that Node drains after the main script and after every single callback, before the loop is allowed to move on:

  1. The process.nextTick queue, until it’s empty.
  2. The promise microtask queue, until it’s empty.
  3. Back to step 1 if either queue picked up new work.
process.nextTick(() => {
  console.log('tick 1');
  Promise.resolve().then(() => console.log('promise from tick'));
  process.nextTick(() => console.log('tick from tick'));
});
Promise.resolve().then(() => {
  console.log('promise 1');
  process.nextTick(() => console.log('tick from promise'));
  Promise.resolve().then(() => console.log('promise from promise'));
});
JavaScript
$ node nested.js
tick 1
tick from tick
promise 1
promise from tick
promise from promise
tick from promise
Terminal

The tick queue drains first, including the tick it added to itself. Then the promise queue drains in FIFO order, and only then does Node notice the tick queued from inside a promise. Since Node 11 this happens between individual timer and immediate callbacks too, matching browsers:

setTimeout(() => {
  console.log('timeout 1');
  Promise.resolve().then(() => console.log('promise inside timeout 1'));
  process.nextTick(() => console.log('nextTick inside timeout 1'));
}, 0);
setTimeout(() => console.log('timeout 2'), 0);
JavaScript
$ node between.js
timeout 1
nextTick inside timeout 1
promise inside timeout 1
timeout 2
Terminal

Predict the output

The classic interview snippet, saved as CommonJS:

console.log('start');
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
Promise.resolve().then(() => console.log('promise'));
process.nextTick(() => console.log('nextTick'));
console.log('end');
JavaScript
$ node basic.cjs
start
end
nextTick
promise
timeout
immediate
Terminal

The first four lines are guaranteed; the last two are not. Over 50 runs, immediate came first 42 times and timeout 8 times, for reasons covered in the next section. Run it as an ES module (basic.mjs) and promise prints before nextTick in all 50 runs: top-level module code runs inside a promise job, so V8 drains promise callbacks before Node gets to the nextTick queue.

Interview tip

If asked “nextTick or promise first?”, say: “nextTick in CommonJS, but ES module top-level code flips it, so I don’t write code that depends on the difference.” Knowing the exception shows you’ve run it.

Inside an I/O callback, everything becomes deterministic. We’re in the poll phase, so the order follows the loop:

const fs = require('node:fs');

fs.readFile(__filename, () => {
  console.log('A: readFile callback (poll)');
  setTimeout(() => console.log('E: setTimeout (timers)'), 0);
  setImmediate(() => console.log('D: setImmediate (check)'));
  Promise.resolve().then(() => console.log('C: promise'));
  process.nextTick(() => console.log('B: nextTick'));
});
JavaScript

This printed A, B, C, D, E in all 100 runs. The close callbacks phase fits between check and the next round of timers:

const net = require('node:net');

const server = net.createServer().listen(0, () => {
  const socket = net.connect(server.address().port, () => {
    setTimeout(() => console.log('timers'), 0);
    setImmediate(() => console.log('check'));
    socket.on('close', () => {
      console.log('close callbacks');
      server.close();
    });
    socket.destroy();
    console.log('connect callback (poll)');
  });
});
JavaScript
$ node close.js
connect callback (poll)
check
close callbacks
timers
Terminal

setTimeout vs setImmediate

Scheduled from the main module, their order depends on timing:

$ cat race.js
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
$ for i in $(seq 1 200); do node race.js | tr '\n' ' '; echo; done | sort | uniq -c
 195 immediate timeout
   5 timeout immediate
Terminal

setTimeout(fn, 0) really means 1ms, because Node raises any delay below 1 to 1. When the loop starts, it checks timers: if 1ms has already passed, the timer runs first; if not, the loop moves on through poll and check, runs the immediate, and catches the timer on the next turn. That depends on process startup and machine load, not on your code. Burn some time before the loop starts and the outcome is fixed:

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

const start = Date.now();
while (Date.now() - start < 5) {} // burn 5ms before the loop starts
JavaScript

That printed timeout first in 200 of 200 runs. Inside an I/O callback there’s no race at all. We’re in poll, check comes next, and timers only come around on the following turn:

const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('timeout'), 0);
  setImmediate(() => console.log('immediate'));
});
JavaScript

That printed immediate first in 200 of 200 runs.

The libuv thread pool

Network I/O needs no extra threads: libuv uses the OS’s non-blocking APIs (epoll, kqueue, IOCP). Work with no such API, or CPU-heavy work, runs on a thread pool, and the result comes back in the poll phase:

  • File system: every async fs API except the file watchers.
  • DNS: dns.lookup(), which uses the system resolver. dns.resolve*() sends network queries instead and skips the pool.
  • Crypto: async pbkdf2, scrypt, randomBytes, randomFill and generateKeyPair.
  • Zlib: all the async compression APIs.

The pool has 4 threads by default. Six hashes show the queueing clearly:

const crypto = require('node:crypto');
const start = performance.now();

for (let i = 1; i <= 6; i++) {
  crypto.pbkdf2('secret', 'salt', 300_000, 64, 'sha512', () => {
    console.log(`hash ${i}: ${Math.round(performance.now() - start)}ms`);
  });
}
JavaScript
$ node pool.js
hash 1: 74ms
hash 2: 75ms
hash 3: 75ms
hash 4: 75ms
hash 6: 145ms
hash 5: 145ms
Terminal

Two hashes wait for a free thread and finish in a second wave. With UV_THREADPOOL_SIZE=6 node pool.js on the same 8-core machine, all six finished in one wave, between 85ms and 113ms. Exact timings and order vary; the shape is the point. Because everything shares one pool, slow disk reads or a burst of dns.lookup() calls can delay unrelated crypto work. Set UV_THREADPOOL_SIZE (maximum 1024) in the environment when the process starts. Assigning process.env.UV_THREADPOOL_SIZE in code only works before anything has used the pool; after one fs.readFile, the same script went back to two waves.

Blocking the loop

Synchronous work delays everything else: timers, I/O callbacks, every other request.

const start = Date.now();
setTimeout(() => console.log(`10ms timer fired after ${Date.now() - start}ms`), 10);

while (Date.now() - start < 200) {} // 200ms of synchronous work
JavaScript
$ node block.js
10ms timer fired after 200ms
Terminal

Usual culprits: JSON.parse or JSON.stringify on huge payloads, sync APIs like readFileSync or pbkdf2Sync in request handlers, catastrophic regexes, and big loops or sorts. monitorEventLoopDelay() from node:perf_hooks measures the damage. There are two fixes. The first is moving CPU work to worker_threads; each worker has its own V8 isolate and event loop:

// fib-worker.js
const { parentPort, workerData } = require('node:worker_threads');

const fib = (n) => (n < 2 ? n : fib(n - 1) + fib(n - 2));
parentPort.postMessage(fib(workerData));
JavaScript
// main.js
const { Worker } = require('node:worker_threads');
const path = require('node:path');

function fibInWorker(n) {
  return new Promise((resolve, reject) => {
    const worker = new Worker(path.join(__dirname, 'fib-worker.js'), { workerData: n });
    worker.once('message', resolve);
    worker.once('error', reject);
  });
}

const heartbeat = setInterval(() => console.log('main thread still responsive'), 100);
fibInWorker(40).then((result) => {
  clearInterval(heartbeat);
  console.log('fib(40) =', result);
});
JavaScript

The heartbeat printed 9 times before fib(40) = 102334155 arrived. Run the same recursion on the main thread and it never prints. In production, reuse a pool of workers rather than starting one per task, since each has real startup cost.

The second fix is chunking: do a slice of the work, then yield with setImmediate so I/O and timers get a turn.

function sumInChunks(items, chunkSize = 100_000) {
  return new Promise((resolve) => {
    let i = 0;
    let total = 0;
    function doChunk() {
      const end = Math.min(i + chunkSize, items.length);
      for (; i < end; i++) total += items[i];
      if (i < items.length) setImmediate(doChunk); // let I/O and timers run
      else resolve(total);
    }
    doChunk();
  });
}
JavaScript

It has to be setImmediate. Yielding with process.nextTick or a resolved promise doesn’t give the loop a turn, because those queues drain completely before it moves on:

let ticks = 0;
setTimeout(() => console.log(`timeout ran after ${ticks} ticks`), 0);

function spin() {
  if (++ticks < 1_000_000) process.nextTick(spin);
}
spin();
JavaScript
$ node starve.js
timeout ran after 1000000 ticks
Terminal

Gotcha

A self-scheduling nextTick or promise chain starves the loop as badly as a while loop, even though each callback is tiny. Timers, I/O and incoming requests all wait.

The interview answer

“Node’s event loop comes from libuv. Each iteration runs timers, pending callbacks, idle and prepare, poll (where I/O callbacks run and the loop waits), check for setImmediate, and close callbacks. After every callback, Node drains the process.nextTick queue, then the promise microtask queue.

setTimeout(0) versus setImmediate is non-deterministic in the main module, because it depends on whether a millisecond passed before the loop checked timers. Inside an I/O callback setImmediate always wins, since check comes right after poll. fs, dns.lookup, async crypto and zlib use libuv’s thread pool, four threads by default, tunable with UV_THREADPOOL_SIZE. JavaScript itself is single-threaded, so I move CPU-heavy work to worker threads or split it into chunks that yield with setImmediate.”

Keep reading

read ✓System Design · hard

Frontend System Design: Build an Autocomplete

A structured walkthrough of the autocomplete design round: requirements, architecture, race-free fetching, caching, rendering, the ARIA combobox and metrics.

~7 min readread →
read ✓JavaScript · mid

Closures, Scope & the Classic setTimeout Loop

What lexical scope and closures really are, why the var + setTimeout loop prints 3 3 3, three ways to fix it, and where closures earn their keep in real code.

~6 min readread →
esc