Cluster vs Worker Threads vs child_process
Node.js gives you three distinct tools for going beyond a single thread, and picking the wrong one is one of the most common scaling mistakes. The node:cluster module forks whole processes to scale I/O-bound servers across CPU cores; node:worker_threads runs parallel JavaScript inside one process for CPU-bound computation; and node:child_process launches external programs or isolated Node scripts. They look similar from a distance, but they solve genuinely different problems. This page compares them side by side and gives you a decision guide.
The three models at a glance
Each model trades off isolation, communication cost, and what kind of work it accelerates. The key question is always: what is my bottleneck? If requests are waiting on the network or disk, you need more processes handling connections. If the CPU itself is pegged by a heavy computation, you need parallel JavaScript. If you need to run a different program entirely, you need a child process.
| Model | Module | Unit | Best for | Memory |
|---|---|---|---|---|
| Cluster | node:cluster | Forked processes sharing a port | Scaling I/O-bound servers across cores | Isolated per process |
| Worker threads | node:worker_threads | Threads in one process | CPU-bound JavaScript inside your app | Sharable via SharedArrayBuffer |
| Child process | node:child_process | Separate OS process | Running external programs / scripts | Fully isolated |
Rule of thumb: cluster scales throughput, worker threads scale computation, child processes run things outside your program. Mixing these up — for example reaching for workers to “speed up” an HTTP server — leads to slower, more complex code.
Cluster: scale an I/O-bound server
The cluster module forks your application into multiple worker processes, each a full copy of your app, and lets them share a single listening socket. The OS load-balances incoming connections across them, so an 8-core machine can handle roughly 8x the concurrent request load of a single Node process. This is the right tool for a typical web API, where each request mostly waits on databases and downstream services.
// server.js
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
import process from 'node:process';
if (cluster.isPrimary) {
const cpus = availableParallelism();
console.log(`Primary ${process.pid} forking ${cpus} workers`);
for (let i = 0; i < cpus; i++) cluster.fork();
cluster.on('exit', (worker) => {
console.log(`Worker ${worker.process.pid} died; restarting`);
cluster.fork();
});
} else {
http
.createServer((req, res) => res.end(`Handled by PID ${process.pid}\n`))
.listen(3000);
console.log(`Worker ${process.pid} listening`);
}
Output:
Primary 51200 forking 8 workers
Worker 51201 listening
Worker 51202 listening
...
Because every worker is a separate process, a crash in one does not take down the others — the primary simply forks a replacement. The trade-off is that processes share nothing: in-memory state, caches, and sessions must live in an external store like Redis.
Worker threads: parallel CPU work
Worker threads do not help with I/O. They exist to move a heavy synchronous computation off the main thread so the event loop stays responsive. Each worker is a real OS thread with its own V8 isolate, communicating through fast message passing and, optionally, shared memory.
// hash-worker.js
import { parentPort, workerData } from 'node:worker_threads';
import { pbkdf2Sync } from 'node:crypto';
const key = pbkdf2Sync(workerData, 'salt', 200_000, 64, 'sha512');
parentPort.postMessage(key.toString('hex'));
// main.js
import { Worker } from 'node:worker_threads';
function hash(password) {
return new Promise((resolve, reject) => {
const worker = new Worker(new URL('./hash-worker.js', import.meta.url), {
workerData: password,
});
worker.on('message', resolve);
worker.on('error', reject);
});
}
console.log(await hash('s3cret'));
The expensive pbkdf2Sync call would block the event loop if run inline; inside a worker it runs in parallel while the main thread keeps serving requests.
Child process: run external commands
Use node:child_process when the work lives outside your JavaScript — invoking ffmpeg, running a Python script, calling a shell utility, or spinning up a fully isolated Node process for sandboxing. Communication happens over stdio streams or IPC, which is slower than thread message passing but gives total isolation.
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const run = promisify(execFile);
const { stdout } = await run('ffprobe', ['-version']);
console.log(stdout.split('\n')[0]);
Output:
ffprobe version 6.1.1 Copyright (c) 2007-2024 the FFmpeg developers
Prefer
execFileorspawnwith an argument array overexec, which runs a shell and is vulnerable to command injection when arguments come from user input.
Decision guide
| Your situation | Reach for |
|---|---|
| HTTP/API server bottlenecked on concurrent connections | Cluster (or a process manager like PM2) |
| Heavy synchronous JS: hashing, image resizing, parsing | Worker threads |
Need to run ffmpeg, a shell command, or another binary | child_process (spawn / execFile) |
| Need crash isolation for untrusted or risky code | child_process (separate process) |
| Sharing large binary buffers between parallel tasks | Worker threads with SharedArrayBuffer |
| Plain network or disk waiting | None — just async/await on the main thread |
In practice these combine. A production API often runs under cluster (one process per core) and inside each worker process uses a small pool of worker threads for the occasional CPU-heavy endpoint, while shelling out to child processes for media conversion. Each layer addresses a different bottleneck.
Best practices
- Diagnose the bottleneck before choosing: I/O concurrency points to cluster, CPU saturation to worker threads.
- Use cluster (or PM2) for HTTP servers and keep all shared state in an external store like Redis or Postgres.
- Reserve worker threads for genuinely CPU-bound JavaScript; never wrap async I/O in a worker.
- Pool both worker threads and child processes rather than spawning one per task — startup cost is significant.
- Always handle
exit/errorevents and restart dead cluster workers and child processes to stay fault-tolerant. - Avoid the
execandeval-style shell paths with untrusted input; pass arguments as an array tospawn/execFile. - Cap parallelism near
os.availableParallelism(); more processes or threads than cores just causes contention.