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.nextTick → Promise.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
- OpenReplay Team, "The Event Loop, Worker Threads, and Concurrency in Node.js"
- Node.js Event Loop Guide (official)
- Node.js: Don't Block the Event Loop (official)
- Node.js Worker Threads documentation
- libuv thread pool documentation
- Piscina - worker thread pool
- Node.js release schedule
This article was written with AI support and reviewed by the author.