- Node.js
- Performance
- Scaling
- Backend
Node.js Clustering Explained: Use Every CPU Core in Production
Node.js runs your JavaScript on one thread, so one process uses one core. A practical guide to the cluster module — forking workers, crash recovery, zero-downtime restarts and the shared-state traps.
· 8 min read
On this page
- How the cluster module works
- The minimal cluster
- Is it actually faster? Measure it
- Production hardening
- 1. Don't respawn in a crash loop
- 2. Shut down gracefully
- 3. Zero-downtime rolling restarts
- The shared-state trap
- Talking between processes (IPC)
- Memory: N workers means N heaps
- cluster vs worker_threads vs PM2
- Checklist
Here's something that surprises a lot of developers the first time they look at htop on a production server: their Node.js API is struggling under load, one CPU core is pinned at 100%, and the other seven are sitting idle.
That's not a bug. Node.js executes your JavaScript on a single thread. One Node process can only ever keep one core busy with your code. If your server has 8 cores, a single node server.js is leaving most of the machine unused.
The cluster module is Node's built-in answer. This guide covers how it works, how to write a production-ready cluster setup, and the mistakes that bite people when they first turn it on.
How the cluster module works#
Clustering runs multiple copies of your app as separate processes:
- One primary process starts first. It doesn't handle HTTP requests itself; it manages workers.
- The primary forks N worker processes. Each worker is a full Node.js process running your server code, with its own event loop and its own memory.
- All workers call
server.listen(3000)on the same port. That would normally fail withEADDRINUSE, but in cluster mode the workers share the listening socket through the primary.
┌──────────────┐
:3000 ──────▶ │ Primary │ (accepts connections, manages workers)
└──────┬───────┘
┌─────────────┼─────────────┐
┌────▼────┐ ┌────▼────┐ ┌────▼────┐
│Worker 1 │ │Worker 2 │ │Worker N │ (each: own event loop + memory)
└─────────┘ └─────────┘ └─────────┘How are connections distributed? On Linux and macOS, the default policy is round-robin: the primary accepts each connection and hands it to the next worker in turn. On Windows, the operating system decides which worker accepts. You can see or change this with cluster.schedulingPolicy, but the default is the right choice almost always.
The minimal cluster#
import cluster from "node:cluster";
import http from "node:http";
import { availableParallelism } from "node:os";
import process from "node:process";
if (cluster.isPrimary) {
const workerCount = availableParallelism(); // number of usable CPU cores
console.log(`Primary ${process.pid} starting ${workerCount} workers`);
for (let i = 0; i < workerCount; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died (${signal || code}). Starting a new one.`);
cluster.fork();
});
} else {
http
.createServer((req, res) => {
res.end(`Handled by worker ${process.pid}\n`);
})
.listen(3000);
console.log(`Worker ${process.pid} listening`);
}Run it and refresh http://localhost:3000 a few times (or use curl in a loop): different PIDs answer.
A few notes on the API:
cluster.isPrimaryreplaced the oldercluster.isMaster(still works, but deprecated).os.availableParallelism()(Node 18.14+) is preferred overos.cpus().length. It returns the number of cores this process is actually allowed to use. (Inside Docker, neither is the whole story; see clustering inside Docker containers.)- In a real app, the worker branch just imports your existing server:
await import("./app.js"). Your Express or Fastify code doesn't change.
Is it actually faster? Measure it#
Clustering helps most when your app is CPU-bound: JSON serialization of big payloads, template rendering, validation, crypto, image work. If every request spends 95% of its time waiting on a slow database, more workers mostly means more processes waiting on the same database.
Don't guess. Measure with autocannon:
# Single process
node app.js &
npx autocannon -c 100 -d 20 http://localhost:3000/api/products
# Clustered
node server.js &
npx autocannon -c 100 -d 20 http://localhost:3000/api/productsCompare requests/sec and the p99 latency, not just the average. Then check whether your bottleneck moved to the database. That tells you whether to scale the app or fix queries.
Production hardening#
The minimal version has three problems in production. Let's fix them one at a time.
1. Don't respawn in a crash loop#
If a worker crashes on startup (bad config, missing env var, a port it can't bind), the code above will fork it again, it crashes again, forever, burning CPU and flooding your logs. Give up if workers die too fast:
const recentCrashes = [];
cluster.on("exit", (worker, code, signal) => {
if (worker.exitedAfterDisconnect) return; // we stopped it on purpose
const now = Date.now();
recentCrashes.push(now);
while (recentCrashes.length && now - recentCrashes[0] > 60_000) recentCrashes.shift();
if (recentCrashes.length > 5) {
console.error("Workers are crash-looping (more than 5 deaths in 60s). Exiting.");
process.exit(1); // let systemd / Docker / PM2 restart us or alert someone
}
console.warn(`Worker ${worker.process.pid} died (${signal || code}). Restarting.`);
cluster.fork();
});worker.exitedAfterDisconnect is true when the worker exited because it was disconnected on purpose (via worker.disconnect()), so graceful shutdowns and rolling restarts don't trigger a respawn.
2. Shut down gracefully#
When you deploy or stop the server, you want in-flight requests to finish instead of being cut off. worker.disconnect() does exactly that inside a worker: it stops accepting new connections, waits for the worker's servers to close, then the worker exits once it has nothing left to do.
let shuttingDown = false;
function shutdown() {
if (shuttingDown) return;
shuttingDown = true;
console.log("Shutting down: finishing in-flight requests…");
for (const worker of Object.values(cluster.workers)) {
worker.disconnect();
}
// Safety net: don't hang forever on a stuck request or keep-alive socket
setTimeout(() => {
console.error("Workers did not exit in time. Forcing shutdown.");
process.exit(1);
}, 10_000).unref();
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
cluster.on("exit", () => {
if (shuttingDown && Object.keys(cluster.workers).length === 0) process.exit(0);
});Inside each worker, close anything else the process holds (database pools, queues) so the event loop can empty:
process.on("disconnect", async () => {
await pool.end(); // e.g. your MySQL pool
});3. Zero-downtime rolling restarts#
Because there are several workers, you can replace them one at a time while the others keep serving traffic. Trigger it with a signal:
import { once } from "node:events";
async function rollingRestart() {
console.log("Rolling restart started");
for (const oldWorker of Object.values(cluster.workers)) {
const newWorker = cluster.fork();
await once(newWorker, "listening"); // new one is ready before the old one leaves
oldWorker.disconnect();
await once(oldWorker, "exit");
}
console.log("Rolling restart complete");
}
process.on("SIGUSR2", rollingRestart);Deploy new code, then kill -USR2 <primary-pid>, and every worker gets replaced with zero dropped requests. (Workers load code when they start, so new workers pick up the new files; the primary itself keeps running the old version of server.js, which is fine as long as only worker code changed.)
The shared-state trap#
This is the single most common clustering bug. Workers don't share memory. Anything you keep in a JavaScript variable exists separately in every worker.
| What breaks | Why | Fix |
|---|---|---|
In-memory sessions (express-session default store) |
User logs in on worker 1; next request hits worker 2, which has never heard of them | Store sessions in Redis or the database, or use stateless JWTs |
| In-memory caches | Each worker has its own copy; invalidating one leaves the others stale | Shared cache (Redis), or broadcast invalidations via IPC (below) |
Rate limiting with a Map |
The real limit becomes N × your limit | Shared store (Redis, or MongoDB if you already have it) |
Cron jobs / setInterval tasks |
Run N times, once per worker | Run scheduled jobs in a separate process, or only in the primary |
| Socket.IO / long-polling | A client's handshake and follow-up requests can land on different workers | Sticky sessions plus a shared adapter (@socket.io/sticky + the Redis adapter) |
| Database connections | Each worker opens its own pool, so connections multiply | Size pools per worker (see MySQL connection pooling) |
Talking between processes (IPC)#
When workers genuinely need to coordinate, use the built-in IPC channel. For example, invalidating a cache key everywhere:
// In a worker: tell the primary
process.send({ type: "cache:invalidate", key: "products" });
// In the primary: relay to every worker
cluster.on("message", (sender, msg) => {
if (msg.type === "cache:invalidate") {
for (const worker of Object.values(cluster.workers)) worker.send(msg);
}
});
// In every worker: act on it
process.on("message", (msg) => {
if (msg.type === "cache:invalidate") localCache.delete(msg.key);
});IPC is fine for occasional control messages. If you find yourself sending data on every request, you want a shared store instead.
Memory: N workers means N heaps#
Each worker is a full process with its own V8 heap. An app that sits at 300 MB will use roughly 300 MB × number of workers. On a 2 GB server with 8 cores, 8 workers can push you into swap or the OOM killer, which is far slower than running fewer workers.
Check per-process memory under realistic load (ps -o pid,rss,cmd -C node or pm2 monit), and cap workers to what fits in RAM, not just CPU count.
cluster vs worker_threads vs PM2#
These get confused a lot:
clusterruns multiple processes to scale a server across cores. Use it for HTTP APIs.worker_threadsruns threads inside one process for CPU-heavy tasks (image resizing, PDF generation, parsing big files) without blocking the event loop. Threads can share memory withSharedArrayBuffer. They don't help a server accept more connections; they keep one request's heavy work from freezing everyone else's.- PM2 cluster mode is the
clustermodule with process management done for you:pm2 start app.js -i maxforks one worker per core, restarts crashed ones, andpm2 reloadperforms exactly the rolling restart above. If you're deploying to a VM, I usually use PM2 rather than hand-writing the primary. That setup is covered in deploying Node.js to EC2 with Nginx and PM2.
Checklist#
- ✅ Use
availableParallelism(), then cap by available memory - ✅ Respawn crashed workers, but stop on a crash loop
- ✅ Graceful shutdown on
SIGTERM/SIGINTwith a forced-exit timeout - ✅ Rolling restarts for zero-downtime deploys
- ✅ No in-memory sessions, caches, rate limits or cron jobs, or handle them explicitly
- ✅ Database pool size × workers stays under the database's connection limit
- ✅ Benchmark before and after; clustering fixes CPU bottlenecks, not slow queries
Clustering is one of the cheapest performance wins in Node.js: a few dozen lines, no new infrastructure, and your app finally uses the whole machine. Just remember that the moment you have more than one process, "in memory" stops meaning "shared."