Back to Blog
Backend

How Node.js Actually Works: The Loop, the Threads, and the Lies

Node is single-threaded — except for the parts that are not. A tour of the event loop's phases, libuv's thread pool, and the failure modes each one produces.

Published
Reading time
11 min read
Author
Yakhya

"Node.js is single-threaded, non-blocking, and event-driven" is the sentence everyone repeats in interviews, and it is roughly a third true. Node runs your JavaScript on one thread. It does not do all of its work on one thread. Knowing exactly where that line falls is the difference between a service that holds a steady p99 under load and one that mysteriously stalls every few seconds.

The pieces

Node is V8 (compiles and runs your JavaScript), libuv (a C library providing the event loop, async I/O, and a thread pool), plus a set of C++ bindings and the JavaScript standard library on top. When you call fs.readFile, JavaScript hands a request to libuv and returns immediately; libuv does the work elsewhere and later pushes a callback back onto the loop. Your code never waits — it is only ever invoked.

The loop has phases, and the order matters

  • timers — callbacks scheduled by setTimeout and setInterval whose threshold has elapsed.
  • pending callbacks — some system-level callbacks deferred from the previous iteration, such as certain TCP errors.
  • idle / prepare — internal to libuv.
  • poll — the heart of it: retrieve new I/O events and run their callbacks. If nothing is pending, the loop blocks here waiting for work.
  • check — setImmediate callbacks, which is why setImmediate fires before timers when scheduled from inside an I/O callback.
  • close callbacks — socket.on('close') and friends.

Between every phase — and between every individual callback — Node drains two microtask queues: process.nextTick first, then resolved promises. This is why an await never yields to I/O in the way people assume: awaiting a resolved promise resumes in the same tick, before the loop ever moves on. It is also why a recursive process.nextTick can starve the loop completely while the process looks perfectly busy.

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

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

fs.readFile(__filename, () => {
  setTimeout(() => console.log('io -> timeout'), 0);
  setImmediate(() => console.log('io -> immediate'));  // always first here
});

Promise.resolve().then(() => console.log('microtask'));
process.nextTick(() => console.log('nextTick'));

// nextTick, microtask, then timeout/immediate in non-deterministic order,
// then io -> immediate, then io -> timeout.

The thread pool nobody budgets for

libuv keeps a pool of four threads by default (UV_THREADPOOL_SIZE). Network I/O does not use it — epoll and kqueue handle sockets natively. What does use it: file system operations, DNS lookups via getaddrinfo (which is why dns.lookup is a pool operation while dns.resolve is not), zlib compression, and crypto functions like pbkdf2 and randomBytes. Five concurrent bcrypt hashes on a default pool means the fifth one waits, and a request that only reads a file gets stuck behind them. If your service does heavy hashing or compression, raising the pool size — or moving that work out of the process — is not premature optimization, it is the fix.

The one true way to break Node

Every millisecond your JavaScript spends computing is a millisecond nothing else in the process runs — no requests accepted, no callbacks fired, no health check answered. A JSON.parse of a 40MB payload, a synchronous crypto call, a regex with catastrophic backtracking, a sort over a million rows: any of these turn your non-blocking server into a blocking one for the duration.

  • Move CPU-bound work to worker_threads, which run separate V8 isolates and communicate by message passing.
  • Chunk long loops with setImmediate so the loop gets to breathe between batches.
  • Stream large payloads instead of buffering them; a stream keeps memory flat and yields between chunks.
  • Monitor event loop lag directly (perf_hooks monitorEventLoopDelay). It is the single most predictive Node metric there is.

Scaling out

One Node process uses one core for JavaScript. To use a 16-core machine you run processes: the cluster module, PM2, or — more commonly today — sixteen small containers behind a load balancer, which gives you the same parallelism plus independent restarts and a scheduler that already knows how to do this. Prefer the latter unless you have a reason not to. Whatever you choose, keep processes stateless: in-memory sessions or caches silently break the moment a second replica appears.

So why use it at all

Because most backend work is waiting — on a database, a cache, another service — and Node's model makes waiting nearly free. Tens of thousands of idle connections cost almost nothing; there is no thread stack per request. Add one language across the stack, the largest package ecosystem in existence, and excellent tooling, and it remains a genuinely strong choice for I/O-bound services, API gateways, real-time systems, and BFF layers. It is a poor choice for number crunching, and it always will be. Use it for what it is.

Tags
Node.jsEvent LooplibuvV8Performance
Keep readingAll Posts