Node.js Process vs Thread vs Cluster
Understand when to reach for child processes, worker threads, or the cluster module to scale Node.js workloads.
What you'll learn
- ✓Why Node.js is single-threaded
- ✓When to use child_process
- ✓When to use worker_threads
- ✓When to use cluster
- ✓Costs and tradeoffs
Prerequisites
- •Familiar with Node.js basics
What and Why
Node.js runs your JavaScript on a single thread. That sounds limiting, but for IO-bound work it is actually a strength: no locks, no context switches, no shared-state bugs. The event loop dispatches IO to libuv’s thread pool and your code keeps moving.
The trouble starts when work is CPU-bound. A long synchronous loop blocks the loop, requests pile up, and the server stops responding. Node provides three escape hatches: child_process, worker_threads, and cluster. They look similar but solve different problems.
Mental Model
child_processspawns a separate OS process with its own V8 instance. Best for shelling out to other programs or isolating untrusted code.worker_threadsspawns a thread inside the same process, sharing the same Node binary but with its own event loop and V8 isolate. Best for offloading CPU work while still sharing memory viaSharedArrayBuffer.clusterforks worker processes that share a server port. The OS round-robins or hashes connections among them. Best for using all CPU cores for an HTTP server.
Pick based on the goal: separate program (child_process), parallel computation (worker_threads), parallel request handling (cluster).
Hands-on Example
A CPU-bound prime check that would block the loop if run inline.
// prime-worker.js
import { parentPort, workerData } from 'node:worker_threads';
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i * i <= n; i++) if (n % i === 0) return false;
return true;
}
parentPort.postMessage(isPrime(workerData));
Call it without blocking the event loop.
import { Worker } from 'node:worker_threads';
function checkPrime(n) {
return new Promise((resolve, reject) => {
const w = new Worker(new URL('./prime-worker.js', import.meta.url), {
workerData: n,
});
w.on('message', resolve);
w.on('error', reject);
w.on('exit', (code) => code !== 0 && reject(new Error(`exit ${code}`)));
});
}
console.log(await checkPrime(9_999_999_967));
Or use cluster to spread an HTTP server across cores.
import cluster from 'node:cluster';
import { cpus } from 'node:os';
import http from 'node:http';
if (cluster.isPrimary) {
for (let i = 0; i < cpus().length; i++) cluster.fork();
cluster.on('exit', () => cluster.fork());
} else {
http.createServer((req, res) => {
res.end(`pid ${process.pid}`);
}).listen(3000);
}
For shelling out to ffmpeg or git, child_process.spawn is the right pick.
import { spawn } from 'node:child_process';
const p = spawn('git', ['rev-parse', 'HEAD']);
for await (const chunk of p.stdout) process.stdout.write(chunk);
child_process -> new OS process -> own memory -> IPC via pipes
worker_threads -> new thread in same process -> shared memory possible
cluster -> forked processes sharing a port -> OS distributes conns Common Pitfalls
- Reaching for clustering when you really need workers. If one request does heavy CPU, clustering only helps if other requests can land on other workers. A truly long-running computation still blocks one worker.
- Sharing big objects between threads.
postMessageclones by default. For large buffers, usetransferListto move ownership without copying. - Treating workers as free. Spawning a worker per task is expensive. Use a pool with
piscinaor roll your own. - Forgetting
exithandlers. Workers can die unexpectedly. Always handleexitand decide whether to restart. - Using cluster behind a proxy that already balances. If your load balancer is fronting many containers, you may not need cluster at all.
Practical Tips
- Profile first. A CPU flame graph tells you whether you really need parallelism or just a smarter algorithm.
- Use a worker pool sized to
os.availableParallelism(). More workers than cores rarely helps and hurts cache locality. - For large data transfers, prefer
SharedArrayBufferplusAtomicsto avoid copies. - In Kubernetes, prefer one Node process per pod over cluster. The platform already handles scaling, restarts, and load balancing.
- Reach for
child_processwhen isolation matters more than performance, like running user-provided code.
Wrap-up
Node.js gives you three concurrency primitives because they solve three different problems. Use child_process for shelling out and isolation. Use worker_threads for parallel CPU work in the same process. Use cluster to fill a multi-core box with one Node app. Match the tool to the goal, measure before and after, and you can keep Node’s single-threaded simplicity for most of your code while still scaling when it matters.
Related articles
- Node.js Node Worker Threads vs Cluster
When to reach for worker_threads versus cluster in Node.js, with a clear mental model, real code, and the pitfalls that bite people in production.
- Node.js Node.js Async Iterators Tutorial
Master async iterators in Node.js for streaming files, paginated APIs, and backpressure-aware data processing.
- Node.js Debugging Node.js Memory Leaks
Find and fix memory leaks in Node.js using heap snapshots, sampling, and a few reliable patterns to avoid leaks.
- Node.js Node.js Pino Logging Tutorial
Set up structured, high-performance logging in Node.js with Pino, including child loggers, redaction, and pretty-printing for development.