One Node process uses one core for JavaScript; production servers usually need all of them.
There are two ways to use more than one CPU core from Node, and they solve different problems.
Worker thread pools speed up CPU-heavy tasks inside one process. Create N long-lived workers (N ≈ os.availableParallelism() minus one for the main thread), queue tasks, and hand each to an idle worker. A pool must handle a worker crashing (replace it, reject its task) and support shutdown. piscina is the standard library for this.
To avoid copying large data:
- Transfer an
ArrayBuffer(postMessage(buf, [buf])) — ownership moves, the sender's copy becomes unusable, zero copying. - Share a
SharedArrayBuffer: both threads see the same memory. Coordinate withAtomics(Atomics.add,Atomics.wait/notify) because plain reads and writes race. - A
MessageChannelgives two workers a direct port to talk without the main thread.
Cluster scales request handling. node:cluster forks the whole program into several processes; the primary process distributes incoming connections to workers (round-robin by default, except on Windows), and all of them appear to listen on the same port. Each worker is a full Node process with its own memory, so in-memory state — sessions, caches, rate-limit counters — is not shared. Move it to Redis or a database.
In containerised deployments, you'll often skip cluster entirely: run one Node process per container and let Kubernetes or the load balancer scale replicas. That gives simpler processes, better isolation and per-instance health checks. Cluster still fits single-VM deployments; PM2's cluster mode wraps the same module.
Whatever you choose, make the processes stateless so any instance can serve any request.
Example
// cluster-server.js — one HTTP worker per core
import cluster from "node:cluster";
import { createServer } from "node:http";
import { availableParallelism } from "node:os";
if (cluster.isPrimary) {
const n = availableParallelism();
console.log(`primary ${process.pid} forking ${n} workers`);
for (let i = 0; i < n; i++) cluster.fork();
cluster.on("exit", (worker, code) => {
console.log(`worker ${worker.process.pid} exited (${code}); replacing`);
cluster.fork();
});
} else {
let served = 0; // per-process state, NOT shared!
createServer((req, res) => {
res.end(`pid ${process.pid} served ${++served}\n`);
}).listen(3000);
}
// curl localhost:3000 repeatedly: different pids answerRemember
- Worker pools parallelise CPU tasks; cluster parallelises request handling
- Transfer or share buffers to avoid copying large data; guard shared memory with Atomics
- Cluster workers share nothing — keep state in external stores
Watch out for
- Keeping sessions or rate limits in process memory, then adding cluster and getting inconsistent behaviour.