gap·map

Node.js runtime internals

[ middle depth ]

How the Node.js event loop phases work

Node runs your JavaScript on one thread and hands slow work to libuv. libuv drives a loop with a fixed set of phases, each with its own callback queue, run in this order every iteration:

  • timers: setTimeout and setInterval callbacks whose delay has elapsed.
  • pending callbacks: a few deferred system callbacks (some TCP error cases).
  • poll: retrieve new I/O events and run their callbacks, which is where most of your fs and socket work lands. The loop can park here waiting for I/O.
  • check: setImmediate callbacks.
  • close: close handlers, like a socket's 'close' event.

You already know micro vs macrotasks from the event-loop module. The Node twist: between every phase, Node empties the process.nextTick queue first, then the promise microtask queue. Those two never wait for the end of the loop; they run at every phase boundary.

Why setTimeout(0) and setImmediate flip order

At the top of a module this pair is a coin flip:

setTimeout(() => console.log("timeout"), 0);
setImmediate(() => console.log("immediate"));
// order varies: depends on whether the ~1ms timer floor has elapsed yet

Inside an I/O callback it is deterministic. You are in the poll phase, and check is the next phase, so setImmediate runs before the timer, which has to wait for the next iteration's timers phase.

Wrong "setTimeout(fn, 0) runs immediately." A timer has a roughly 1ms minimum and only fires when the loop reaches the timers phase, which can be many I/O callbacks away.

What actually runs on the thread pool

Only some async APIs use libuv's thread pool: all fs operations, dns.lookup, parts of crypto (pbkdf2, randomBytes, scrypt), and zlib. Network sockets do not. They use the OS async layer (epoll, kqueue, IOCP), so ten thousand idle connections cost zero threads. The default pool is four threads, so five concurrent fs reads means one waits its turn.

The bug this creates is a request handler that calls crypto.pbkdf2Sync or JSON.parse on a 5MB body. That holds the one JS thread, and every other client's callback stalls behind it. The async variants keep the loop free. Synchronous CPU work on a hot path is the Node latency bug you will see most often.

[ senior depth ]

The thread pool is small and easy to starve

The default is four threads. UV_THREADPOOL_SIZE is read once, at first thread pool use, and preallocated; changing the variable after that does nothing. Since fs, dns.lookup, crypto, and zlib all draw from those four threads, one greedy consumer starves the rest.

The trap interviewers reach for is DNS. dns.lookup (what http.request and most database drivers call under the hood) runs getaddrinfo on the thread pool. A burst of outbound requests can saturate all four threads and stall your fs reads behind them. dns.resolve4 goes through c-ares over the network and never touches the pool.

// runs on the thread pool, competes with fs/crypto under load:
dns.lookup("api.example.com", cb);
// async network via c-ares, pool untouched:
dns.resolve4("api.example.com", cb);

Wrong "raise UV_THREADPOOL_SIZE and my CPU-bound route goes parallel." The pool only runs Node's built-in C++ work. Your JavaScript function never executes there, whatever the pool size.

worker_threads vs cluster vs child_process

Three tools with different shapes:

  • worker_threads: a new V8 isolate with its own event loop, inside the same process. For CPU-bound JavaScript such as parsing, hashing, or image work. Share raw memory with SharedArrayBuffer; otherwise messages are structured-cloned across a MessagePort.
  • cluster: forks whole processes that share one listen socket, and the primary round-robins incoming connections across them. For scaling an I/O server across cores with fault isolation, not for a single heavy computation.
  • child_process: spawn any process, including non-Node programs, and talk over stdio or an IPC channel.

Asked "my resize route is slow," the senior answer is a worker_threads pool, not cluster. cluster gives you N event loops, each still frozen by the same synchronous resize; only threads (or separate processes) buy you real parallel execution of that JavaScript.

What interviewers grade

Can you place process.nextTick against both promises and the phases: higher priority than promise microtasks, drained at every phase boundary rather than once per loop? Do you know that recursively scheduling nextTick starves I/O, which is why setImmediate is the safe way to yield back to the loop? Can you state that network I/O skips the thread pool entirely? And can you measure a blocked loop instead of guessing: perf_hooks.monitorEventLoopDelay() or an event-loop-lag gauge, with a climbing p99 as the tell.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

unlocks

more Node.js

was this useful?