The Cluster Module
Node.js runs your JavaScript on a single thread, so a single Node process can only ever use one CPU core no matter how many cores the machine has. The cluster module solves that by letting one Node program spawn several copies of itself — one per core — that all listen on the same port and share incoming work. It’s the classic way to turn a single-core HTTP server into a multi-core one without touching your application logic.
Overview / How it works
A clustered program has two kinds of processes: exactly one primary (formerly called “master”) and any number of workers. The primary process is the one that first runs your script. When it calls cluster.fork(), Node spawns a brand-new child process that re-runs the same script file from the top — but this time cluster.isPrimary is false and cluster.isWorker is true, so the worker branch of your if statement runs instead. Every worker is a completely separate OS process with its own V8 instance, its own event loop, its own heap, and its own memory. Nothing is shared automatically — workers only talk to the primary (and to each other, indirectly) through an IPC (inter-process communication) channel that fork() sets up for you.
The clever part is how multiple workers can all listen() on the same port without a conflict. When a worker calls server.listen(3000), it doesn’t actually open the socket itself. It sends a message to the primary, which owns the real listening socket and hands connections out to workers. On every platform except Windows, the default strategy is round-robin (cluster.SCHED_RR): the primary accepts each new connection and passes it to the next worker in rotation. On Windows, the default is cluster.SCHED_NONE, which hands the raw socket handle to the OS and lets it decide — this tends to load connections unevenly toward whichever worker is fastest to accept. You can force round-robin everywhere by setting cluster.schedulingPolicy = cluster.SCHED_RR before forking any workers.
This is different from the node:worker_threads module, which runs multiple threads inside one process and can share memory via SharedArrayBuffer. cluster gives you full process isolation (a crash in one worker can’t corrupt another’s memory) at the cost of higher memory usage and no shared state. Use cluster to scale a stateless HTTP/TCP server across cores; use worker_threads for CPU-heavy computation that needs to share data cheaply.
Syntax
const cluster = require("node:cluster");
if (cluster.isPrimary) {
cluster.fork(); // spawn a worker process
} else {
// worker-only code, e.g. start an HTTP server
}
| Member | What it does |
|---|---|
cluster.isPrimary |
True in the original process that ran the script (Node 16+; the older name isMaster is a deprecated alias). |
cluster.isWorker |
True inside a forked worker process. |
cluster.fork([env]) |
Spawns a new worker process, optionally with extra environment variables. Returns a Worker object. |
cluster.workers |
An object mapping worker id to Worker instances, available in the primary. |
worker.process |
The underlying ChildProcess — use worker.process.pid to get its PID. |
worker.send(message) |
Sends a message to that worker over IPC; the worker receives it via process.on("message", ...). |
worker.disconnect() |
Asks a worker to close its IPC channel and stop accepting new connections gracefully. |
cluster.on(event, fn) |
Listens for cluster-wide events: "fork", "online", "listening", "disconnect", "exit". |
Examples
1. A basic clustered HTTP server
This forks one worker per available CPU core, and every worker runs its own copy of the HTTP server. The primary also listens for a worker dying and immediately forks a replacement so the app keeps its full capacity.
const cluster = require("node:cluster");
const http = require("node:http");
const os = require("node:os");
if (cluster.isPrimary) {
const numCPUs = os.availableParallelism();
console.log(`Primary ${process.pid} is running, forking ${numCPUs} workers`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died (code: ${code}, signal: ${signal})`);
console.log("Starting a new worker...");
cluster.fork();
});
} else {
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(`Handled by worker ${process.pid}\n`);
});
server.listen(3000, () => {
console.log(`Worker ${process.pid} started and listening on port 3000`);
});
}
node app.js
curl http://localhost:3000
curl http://localhost:3000
curl http://localhost:3000
Output:
Primary 12345 is running, forking 8 workers
Worker 12346 started and listening on port 3000
Worker 12347 started and listening on port 3000
...
Handled by worker 12346
Handled by worker 12349
Handled by worker 12351
Notice the successive curl requests get answered by different worker PIDs — that’s the round-robin scheduler at work. os.availableParallelism() (Node 19.4+) is preferred over the older os.cpus().length because it respects CPU quotas set by containers (Docker, Kubernetes), so you don’t over-fork inside a container that’s limited to, say, 2 cores.
2. Aggregating stats with IPC messages
Workers don’t share memory, so a global counter in each worker only counts requests that worker handled. To get a true total, workers report each request back to the primary over the IPC channel, and the primary keeps the single authoritative count.
const cluster = require("node:cluster");
const http = require("node:http");
const os = require("node:os");
if (cluster.isPrimary) {
const numCPUs = os.availableParallelism();
let totalRequests = 0;
for (let i = 0; i < numCPUs; i++) {
const worker = cluster.fork();
worker.on("message", (msg) => {
if (msg.type === "request-handled") {
totalRequests++;
console.log(`Total requests handled so far: ${totalRequests}`);
}
});
}
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} exited, restarting...`);
cluster.fork();
});
} else {
const server = http.createServer((req, res) => {
process.send({ type: "request-handled" });
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(`Handled by worker ${process.pid}\n`);
});
server.listen(3000);
}
Output:
Total requests handled so far: 1
Total requests handled so far: 2
Total requests handled so far: 3
The counter increases by exactly one for every request any worker handles, because it lives in one place (the primary) and every worker reports to it instead of keeping its own local, incomplete copy.
3. Graceful shutdown
Killing worker processes abruptly can cut off in-flight requests. A well-behaved cluster listens for a termination signal, tells each worker to stop accepting new connections, and lets existing requests finish before the worker exits.
const cluster = require("node:cluster");
const http = require("node:http");
const os = require("node:os");
if (cluster.isPrimary) {
const numCPUs = os.availableParallelism();
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
process.on("SIGTERM", () => {
console.log("Primary received SIGTERM, shutting down workers...");
for (const id in cluster.workers) {
cluster.workers[id].disconnect();
}
});
} else {
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end(`Handled by worker ${process.pid}\n`);
});
server.listen(3000);
process.on("disconnect", () => {
server.close(() => {
console.log(`Worker ${process.pid} closed all connections, exiting`);
process.exit(0);
});
});
}
Output:
Primary received SIGTERM, shutting down workers...
Worker 12346 closed all connections, exiting
Worker 12347 closed all connections, exiting
worker.disconnect() closes the IPC channel and, for an HTTP server, stops the worker from receiving new connections — the worker’s own "disconnect" listener then calls server.close(), which waits for existing responses to finish before its callback fires.
How it works step by step
- You run
node app.js. The script starts executing andcluster.isPrimaryistrue. - The primary calls
cluster.fork()once per CPU. Each call spawns a full new Node.js OS process running the exact sameapp.jsfile, connected to the primary via an IPC pipe. - Inside each new process,
cluster.isPrimaryis nowfalse, so the worker branch runs and callsserver.listen(3000). - Rather than opening port 3000 itself, the worker’s
listen()call is intercepted by the cluster module and sent to the primary, which is the only process that actually owns the listening socket. - When a real client connection arrives, the primary’s OS-level accept loop picks the next worker in round-robin order and hands the connection off to it over the IPC channel.
- That worker’s own event loop and HTTP parser handle the request end-to-end and write the response directly to the client — the primary is not in the response path.
- If a worker process crashes (uncaught exception,
process.exit(), or being killed), the primary receives an"exit"event with the exit code and signal. If you don’t re-fork there, total capacity permanently drops by one worker.
Common Mistakes
Mistake 1: assuming workers share memory
Each worker is a separate process with its own heap. A variable declared in one worker is invisible to the others — there is no shared global state.
// Wrong: this counter only ever reflects requests THIS worker handled
let requestCount = 0;
const server = http.createServer((req, res) => {
requestCount++;
res.end(`This worker has handled ${requestCount} requests\n`);
});
server.listen(3000);
With 8 workers, a client hitting this endpoint repeatedly will see the number jump around unpredictably (or even reset to 1) because each request may land on a different worker with its own counter. Report the event to the primary instead, and let the primary keep the single source of truth, as shown in Example 2:
process.send({ type: "request-handled" });
For anything that must persist or be consistent across workers — sessions, rate limits, caches — use an external store like Redis, not in-process variables.
Mistake 2: forking workers but never handling crashes
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// if a worker crashes here, it just vanishes —
// capacity silently drops and nobody is told
Without an "exit" listener, a worker that throws an uncaught exception simply disappears. Your app keeps running, just with less capacity, and nothing tells you it happened until load climbs and requests start timing out. Always listen for exits and re-fork:
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died, forking a replacement`);
cluster.fork();
});
A related trap: sockets and WebSocket connections are sticky to whichever worker accepted them. If a client reconnects, round-robin may route it to a different worker that has no memory of its previous connection. If you need session affinity, route by client IP hash (cluster.schedulingPolicy doesn’t support this directly — you’d typically put a reverse proxy like nginx in front with IP-hash load balancing) or keep session state in Redis so any worker can serve any client.
Best Practices
- Use
os.availableParallelism()instead ofos.cpus().lengthso your worker count respects container CPU limits (Node 19.4+). - Always attach a
cluster.on("exit", ...)handler in the primary that re-forks, so a crashing worker doesn’t silently shrink your capacity. - Keep the primary process’s own logic tiny — it should fork, monitor, and relay signals, not do request handling itself.
- Never store request-scoped or session state only in worker memory; use an external store (Redis, a database) if any state must be visible to every worker.
- Handle
SIGTERM/SIGINTin the primary and disconnect workers gracefully instead of hard-killing the process group. - Log the worker’s PID with every important event (
fork,online,exit) so you can correlate crashes with request logs. - In containerized deployments (Docker/Kubernetes), consider letting the orchestrator run multiple single-process container replicas instead of clustering inside one container — it gives you per-instance health checks, rolling restarts, and horizontal scaling across machines, which
clusteralone can’t do. - For production process management on a single VM, tools like PM2 wrap the same clustering behavior with extra features (log rotation, zero-downtime reloads) — know the underlying
clustermodule so you understand what they’re doing.
Practice Exercises
- Exercise 1: Extend Example 2 so the primary prints a summary every 10 seconds (using
setInterval) showing total requests handled and the number of currently alive workers, instead of logging on every single request. - Exercise 2: Modify Example 3 so that if a worker doesn’t finish closing within 5 seconds of receiving
SIGTERM, the primary force-kills it withworker.process.kill(). Hint: usesetTimeoutper worker and clear it in the"exit"handler. - Exercise 3: Write a clustered server where each worker crashes intentionally after handling 5 requests (
process.exit(1)), and verify in the terminal output that the primary always restores the worker count back toos.availableParallelism().
Summary
- The
clustermodule lets one Node.js primary process fork multiple worker processes so a server can use every CPU core, since Node itself is single-threaded per process. - Workers are full, isolated OS processes — they share nothing automatically and communicate only through the IPC channel that
fork()creates. - The primary owns the actual listening socket and distributes connections to workers, round-robin by default on every platform except Windows.
- Always handle the
"exit"event and re-fork, or a crashing worker permanently reduces your app’s capacity. - Use
os.availableParallelism(), notos.cpus().length, so worker counts respect container CPU limits. - Because there’s no shared memory, cross-worker state (sessions, counters, caches) must live in an external store, not in a plain JavaScript variable.
clustersolves multi-core CPU utilization on one machine; it’s complementary to, not a replacement for, running multiple machine or container replicas behind a load balancer.
