Scaling Node.js: Cluster Module and Worker Threads
Node is single-threaded until it isn't. When to reach for the cluster module, when worker threads are the right tool, and when neither is the answer.
The most persistent myth about Node.js is that it cannot use more than one CPU core. The truth is more useful: a single Node process runs your JavaScript on one thread, and the platform gives you two distinct tools for going wider. They solve different problems, and mixing them up is a common source of disappointing benchmarks.
The cluster module: more processes, more requests
The cluster module forks your server into multiple processes that share a single listening port. The primary process accepts connections and distributes them to workers round-robin. Because each worker is a full process with its own event loop and heap, an eight-core machine can genuinely serve roughly eight times the request throughput of a single process for typical I/O-bound APIs.
import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
if (cluster.isPrimary) {
for (let i = 0; i < availableParallelism(); i++) {
cluster.fork();
}
cluster.on('exit', () => cluster.fork());
} else {
startServer(); // your normal app entry point
}The exit handler matters: it restarts crashed workers, which is a free resilience win. The caveat is that workers share nothing. In-memory caches, rate-limit counters and WebSocket rooms all silently break when they exist eight times over — that state has to move to Redis or similar before clustering.
Clustering also unlocks zero-downtime restarts on a single machine: fork fresh workers, wait for each to start listening, then disconnect the old ones so they finish their in-flight requests before exiting. Process managers such as PM2 package this reload dance for you, and on a traditional VPS it remains the simplest blue-green deployment available.
Worker threads: keeping the event loop free
Worker threads solve the opposite problem. If a request handler does heavy CPU work — image resizing, PDF generation, parsing a huge XML document — it blocks the event loop, and every other request on that process waits. Worker threads run JavaScript on separate threads within one process, so the expensive work happens off the main loop.
import { Worker } from 'node:worker_threads';
function renderPdf(invoice) {
return new Promise((resolve, reject) => {
const worker = new Worker('./pdf-worker.js', {
workerData: invoice,
});
worker.once('message', resolve);
worker.once('error', reject);
});
}In practice you should never spawn a worker per task as the example implies — thread startup costs milliseconds and memory. Use a pool library such as piscina, size it to the number of cores, and treat the pool like any other bounded resource.
Be aware of messaging costs, too. Data passed to a worker is structured-cloned by default, which is fine for small payloads but expensive for large ones; transfer ArrayBuffers or use SharedArrayBuffer when moving megabytes. As a rule of thumb, the job should cost meaningfully more CPU than the marshalling — tens of milliseconds at minimum — or the pool spends its time copying rather than computing.
Which one do you actually need?
- API is I/O-bound and one core is maxed out: cluster, or better, multiple containers behind a load balancer.
- Occasional CPU-heavy tasks stall unrelated requests: worker threads via a pool.
- Sustained heavy CPU work is the core product: consider a separate job service, or a different runtime for that component.
- Running on Kubernetes with one core per pod: neither — scale horizontally with replicas and keep processes simple.
That last point deserves emphasis. In containerised deployments the orchestrator is your cluster module, and running one process per container keeps memory limits, health checks and crash semantics clean. We mostly see the cluster module earn its keep on bare VMs and traditional hosts.
Measure before you reach for either tool. A pegged event loop shows up clearly in event-loop delay metrics, and fixing an accidental synchronous JSON.parse of a 50 MB payload will do more than any amount of clustering.