This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive /

Node.js Concurrency Is Not a Mystery - You Just Never Learned the Event Loop

Your server is slow because you are fighting a runtime you never learned

Node.js Concurrency Is Not a Mystery - You Just Never Learned the Event Loop

Article content

Your server is slow because you are fighting a runtime you never learned

Every Node.js developer has a story about the endpoint that worked fine in development and collapsed in production. The CPU spiked. The event loop stalled. Health checks started failing. And someone on the team said the words that have launched a thousand misdiagnoses: "Node is single-threaded, we need more servers."

Node.js is not single-threaded. Your JavaScript runs on one thread. The process uses several. Conflating the two is not a minor technicality - it is the root cause of most Node performance incidents I have seen in fifteen years of reviewing backend code.

The OpenReplay Team wrote a precise, layered breakdown of how the event loop, libuv thread pool, and worker threads actually work. What follows builds on their technical blueprint and adds what every senior backend engineer should internalize: the mental model that makes the right concurrency tool obvious before you touch a config file.

Stop saying "Node is single-threaded"

Here is the sentence you need to memorize: Node.js executes your JavaScript on one thread. The runtime is not single-threaded. libuv maintains a pool of background threads, and the operating system handles network I/O on Node's behalf. Treat "single-threaded" as a statement about where your code runs, not about the process as a whole. Get this wrong and you will reach for cluster when you need a worker, or bump UV_THREADPOOL_SIZE when you need to refactor a blocking function.

The distinction that matters is between concurrency and parallelism:

  • Concurrency: multiple tasks make progress over the same period by interleaving on a shared resource. A single-core machine running Node is concurrent but not parallel. The event loop rapidly switches between in-flight operations, but only one piece of JavaScript executes at any instant.
  • Parallelism: multiple tasks execute at the same instant on separate cores. Worker threads and cluster add genuine parallelism.

The practical consequence: concurrency solves waiting problems (I/O). Parallelism solves computing problems (CPU). Reaching for the wrong one is how slow endpoints happen.

The event loop is six phases, not "magic"

Every Node.js tutorial hand-waves the event loop as "the thing that makes async work." That is like saying "the engine makes the car go." The event loop runs in a fixed cycle of six phases. Each phase has its own queue of callbacks that it drains completely before moving to the next:

┌───────────────────────────┐
│  1. timers                │  setTimeout(), setInterval()
├───────────────────────────┤
│  2. pending callbacks     │  Deferred system callbacks
├───────────────────────────┤
│  3. idle, prepare         │  Internal libuv bookkeeping
├───────────────────────────┤
│  4. poll                  │  New I/O events; blocks here if idle
├───────────────────────────┤
│  5. check                 │  setImmediate()
├───────────────────────────┤
│  6. close callbacks       │  socket.on('close', ...)
└───────────────────────────┘

Phase 3 (idle, prepare) is real. Many explainers omit it because you never schedule into it directly. It exists for libuv's internal housekeeping. I mention it because omitting it is how developers grow up thinking the event loop is five phases and then get confused when something runs between poll and check.

Microtasks are not a phase - and the order matters

This is where most interview candidates fail and most production incidents begin. Microtasks are not a phase of the event loop. They drain between phases, and they have a strict internal order:

process.nextTick()  →  drains first
Promise.then()      →  drains second
macrotasks          →  timers, I/O, setImmediate - only after both microtask queues are empty

process.nextTick outranks Promise.then, which outranks setTimeout. This is not an implementation detail. It is a three-tier priority system baked into the runtime, and if you do not know it, you will write code whose execution order surprises you in production.

// Run on Node.js 24. Prove it to yourself.
const fs = require('node:fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('1: setTimeout(0)'), 0);
  setImmediate(() => console.log('2: setImmediate'));
  Promise.resolve().then(() => console.log('3: promise'));
  process.nextTick(() => console.log('4: nextTick'));
});

// Prints:
// 4: nextTick
// 3: promise
// 2: setImmediate
// 1: setTimeout(0)

nextTick drains first. Then the promise microtask queue. Then - because we are inside an I/O callback (poll phase) - the loop hits the check phase next, so setImmediate fires before the loop wraps around to the timers phase. Schedule the same setTimeout(0) and setImmediate() at the top level instead of inside an I/O callback, and the order is non-deterministic. Do not rely on it. This is the kind of thing that works on your machine and fails in CI.

The rule: process.nextTickPromise.then → macrotasks. Always. Every time. No exceptions.

The libuv thread pool: what actually uses it (and what doesn't)

The libuv thread pool is a fixed set of background threads - 4 by default, expandable to 1024 - that libuv uses to run operations with no non-blocking OS primitive. The pool is shared across all event loops in a process.

Here is the list of what runs on it, and this list is finite and specific:

Uses the pool Does NOT use the pool
fs APIs (async variants) Network sockets (epoll/kqueue/IOCP)
dns.lookup() (calls getaddrinfo) dns.resolve*() (uses c-ares, bypasses pool)
crypto.pbkdf2(), crypto.scrypt() Regular TCP/HTTP requests
crypto.randomBytes(), crypto.generateKeyPair() dns.resolve() family
zlib compression Event loop callbacks

Network I/O does not touch the thread pool. It goes through the operating system's polling mechanism - epoll on Linux, kqueue on macOS, IOCP on Windows - and surfaces directly in the poll phase. The dns distinction trips people up constantly: dns.lookup() uses the pool, the dns.resolve*() family does not. If your mental model was "DNS uses the pool," it is wrong. Half of DNS does. The other half doesn't.

# Set BEFORE Node starts. The pool preallocates on first use.
# Mutating process.env.UV_THREADPOOL_SIZE after the pool is touched does nothing.
UV_THREADPOOL_SIZE=8 node server.js

The myth that kills production servers

"Bump UV_THREADPOOL_SIZE to speed up my API." No. Raising UV_THREADPOOL_SIZE speeds up concurrent pool-backed I/O - more parallel fs reads, more parallel crypto operations. It will never, ever speed up CPU-bound JavaScript. The pool runs libuv's C-level operations, not your functions. If your endpoint is slow because fib(45) runs on the main thread, UV_THREADPOOL_SIZE=1024 does absolutely nothing. You need a worker thread.

Worker threads: they are not "just OS threads"

A worker thread is not an OS thread with a JavaScript face. It is a separate V8 isolate with its own event loop and its own libuv loop. This is why workers cannot share ordinary JavaScript objects. It is not a limitation. It is the architecture. Everything you postMessage is deep-copied via the HTML structured clone algorithm:

// main.js
const { Worker } = require('node:worker_threads');

const worker = new Worker('./fib-worker.js', { workerData: { n: 42 } });
worker.on('message', (result) => console.log('fib(42) =', result));
worker.on('error', (err) => console.error(err));

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

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

parentPort.postMessage(fib(workerData.n));

Functions, class prototypes, and live references do not survive postMessage. The only escape from copying is SharedArrayBuffer - shared memory with all the synchronization responsibility that implies. This model trades shared-memory-by-default (and the locks, mutexes, and race conditions of C++/Java threading) for isolation-by-default. Safer, with copying as the cost. It is a deliberate design choice, not a missing feature.

Do not spawn a worker per request

The Node.js documentation is explicit about this, and everyone ignores it: spawning a Worker for every HTTP request is wasteful. The overhead of creating a V8 isolate, a libuv loop, and an event loop exceeds the benefit for anything but the heaviest computations. Use a pool. The community standard is piscina:

const Piscina = require('piscina');
const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });

app.get('/report', async (req, res) => {
  const value = await pool.run({ n: 45 }); // Worker handles it; loop stays free
  res.json({ value });
});

One pool, reused across requests. Workers stay alive. Zero startup cost per task.

The corollary: don't move already-async work into a worker

If you wrap crypto.pbkdf2 in a worker thread, you have achieved nothing. That function already runs on the libuv thread pool. You have scheduled a thread (the worker) to schedule another thread (libuv). Congratulations, you added latency and memory for zero benefit. Workers earn their keep only for synchronous, CPU-bound JavaScript - heavy computation that would otherwise freeze the main thread.

The blocking route that kills your health checks

Here is the failure mode that takes down production:

// server-blocking.js - THE WRONG WAY
const express = require('express');
const app = express();

function fib(n) {
  return n < 2 ? n : fib(n - 1) + fib(n - 2);
}

app.get('/report', (req, res) => {
  res.json({ value: fib(45) }); // Blocks the event loop FOR EVERYONE
});

app.get('/health', (req, res) => res.send('ok'));

app.listen(3000);
// While fib(45) runs, /health returns NOTHING. Every request queues.

The production signature of a blocked event loop is distinctive: many concurrent users stall at the same wall-clock instant. Not one user's bad network. Not a slow database query for one session. Everyone freezes simultaneously because the single JavaScript thread is busy computing. You can confirm it at runtime with monitorEventLoopDelay from node:perf_hooks - a high p99 means the loop is saturated.

The fix is a worker pool:

// server-pooled.js - THE RIGHT WAY
const Piscina = require('piscina');
const pool = new Piscina({ filename: path.resolve(__dirname, 'fib-task.js') });

app.get('/report', async (req, res) => {
  const value = await pool.run({ n: 45 }); // Runs on a worker; loop stays free
  res.json({ value });
});

app.get('/health', (req, res) => res.send('ok')); // Always responds immediately

Cluster: scaling I/O across cores (not the same thing as workers)

Cluster forks multiple processes, each with its own V8 isolate, its own event loop, its own memory, sharing a listening socket. Workers are multiple threads within one process. The distinction is not academic:

Worker Threads Cluster
Unit Thread within a process Separate OS process
Memory Can share via SharedArrayBuffer Fully isolated, IPC only
Use case CPU-bound JavaScript off the main thread Scale I/O-bound throughput across cores
Overhead V8 isolate + structured clone Full process + IPC serialization

In practice, a high-throughput service often uses both: cluster (or container replicas) to span cores, and a worker pool inside each process to absorb occasional CPU spikes. The mental model is not "cluster OR workers." It is "cluster for I/O scaling, workers for CPU offloading, and yes you probably want both."

The decision guide that replaces Stack Overflow

Stop searching "Node.js concurrency best practice." The answer depends on your bottleneck, and there are exactly four tools:

WHAT IS YOUR BOTTLENECK?
─────────────────────────────────────────────────────────
Is it I/O-bound (network, DB, files)?
  └── async/await. The event loop already handles this.
      Do not add workers. Do not add cluster.
      The platform solved this. Use it.

Is it CPU-bound JavaScript (computation, parsing, crypto)?
  └── Worker threads, behind a pool.
      Do not bump UV_THREADPOOL_SIZE. It won't help.

Is one core saturated under concurrent I/O traffic?
  └── Cluster across cores (or run multiple container replicas).
      This is about throughput, not computation.

Are you spawning a worker per request?
  └── Stop. Use piscina. You are paying startup cost for nothing.
─────────────────────────────────────────────────────────

Four tools. Four bottlenecks. Four answers. Everything else is premature optimization or cargo-cult config tweaking.

Tool Runs JS in parallel? Best for Main cost
async/await + event loop No I/O-bound work Blocks if you do CPU work on main thread
libuv thread pool No (runs C, not your JS) fs, dns.lookup, crypto, zlib Fixed size; never accelerates your JS
Worker threads + pool Yes CPU-bound JavaScript Startup + structured-clone copying
Cluster / replica processes Yes Scaling I/O load across cores Process overhead; no shared state

Profile before you parallelize

The most expensive mistake in Node.js performance tuning is optimizing the wrong bottleneck. You think your endpoint is slow because of I/O, so you add cluster. It was actually a synchronous JSON.parse on a 5 MB payload blocking the event loop. Cluster added four more processes all blocking on the same JSON.parse. You made it worse.

Before you touch UV_THREADPOOL_SIZE, before you add cluster, before you reach for workers, run monitorEventLoopDelay. If the p99 is high, the event loop is saturated. Then ask: is it saturated because of I/O (add cluster/replicas) or because of CPU (add workers)? The answer is in the data. The data is in node:perf_hooks. Use it.

const { monitorEventLoopDelay } = require('node:perf_hooks');
const histogram = monitorEventLoopDelay({ resolution: 20 });
histogram.enable();

// Let it collect for a while under load, then:
setTimeout(() => {
  histogram.disable();
  console.log('Event loop delay (ms):');
  console.log('  min:', histogram.min / 1e6);
  console.log('  p50:', histogram.mean / 1e6);
  console.log('  p99:', histogram.percentile(99) / 1e6);
  console.log('  max:', histogram.max / 1e6);
}, 30000);

If p99 is above 10ms, your users feel it. Above 50ms, your health checks are at risk. Above 100ms, you are one traffic spike away from an incident. The numbers do not lie. Your intuition about where the bottleneck is probably does.

The runtime is not magic. Learn it.

The OpenReplay Team's article ends with a mental model worth tattooing on your debugging workflow: stop asking "is Node single-threaded?" and start asking "what is my bottleneck?" Waiting is the event loop's job. Computing in parallel is the worker pool's. Spreading load across cores is cluster's.

Profile the slow endpoint. Identify whether it is stuck waiting or stuck computing. The right tool follows directly. Everything else is guessing, and guessing in production is how 3 AM incidents happen.

Node.js gives you four concurrency primitives. They are well-documented, well-tested, and stable. The reason your server is slow is not that Node is inadequate. It is that you never sat down and learned what your runtime actually does.

Now you know. Go fix that endpoint.

References


This article was written with AI support and reviewed by the author.

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.