Node.js Cluster Module — Scale to Multiple CPU Cores 2026
Advertisement
Introduction
Why This Matters
A standard Node.js HTTP server uses one CPU core, leaving the rest idle. On an 8-core machine, this means 87.5% of your compute capacity is unused. The cluster module forks multiple worker processes that share a single port, distributing incoming connections across all cores.
Unlike worker threads (which share memory), cluster workers are separate operating system processes — each with its own heap, event loop, and module cache. This provides true fault isolation: a worker crash does not affect other workers or the primary process.
Basic Clustering Setup
import cluster from 'cluster';
import os from 'os';
import http from 'http';
const PORT = 3000;
const NUM_WORKERS = os.cpus().length;
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} starting ${NUM_WORKERS} workers`);
for (let i = 0; i < NUM_WORKERS; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.warn(
`Worker ${worker.process.pid} exited (code: ${code}, signal: ${signal})`
);
// Restart immediately on unexpected exit
if (!worker.exitedAfterDisconnect) {
console.log('Restarting worker...');
cluster.fork();
}
});
} else {
// Each worker runs its own HTTP server
http
.createServer((req, res) => {
res.writeHead(200);
res.end(`Handled by worker ${process.pid}\n`);
})
.listen(PORT, () => {
console.log(`Worker ${process.pid} listening on port ${PORT}`);
});
}The OS distributes incoming connections across workers using round-robin scheduling (default on non-Windows). All workers bind to the same port — the kernel handles the distribution.
Integrating with Express and TypeScript
// cluster.ts
import cluster from 'cluster';
import os from 'os';
const NUM_WORKERS = Number(process.env.WORKERS) || os.cpus().length;
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
console.log(`Forking ${NUM_WORKERS} workers...`);
for (let i = 0; i < NUM_WORKERS; i++) {
cluster.fork();
}
cluster.on('online', (worker) => {
console.log(`Worker ${worker.process.pid} is online`);
});
cluster.on('exit', (worker, code) => {
if (code !== 0 && !worker.exitedAfterDisconnect) {
console.log(`Worker ${worker.process.pid} crashed. Restarting...`);
cluster.fork();
}
});
} else {
// Import and start the Express app in each worker
require('./app');
}// app.ts
import express from 'express';
const app = express();
app.use(express.json());
app.get('/health', (_req, res) => {
res.json({ pid: process.pid, status: 'ok' });
});
app.get('/compute', (_req, res) => {
// Simulate CPU work
let n = 0;
for (let i = 0; i < 10_000_000; i++) n += i;
res.json({ result: n, pid: process.pid });
});
const PORT = Number(process.env.PORT) || 3000;
app.listen(PORT, () => {
console.log(`Worker ${process.pid} listening on port ${PORT}`);
});Graceful Restart Without Downtime
Zero-downtime restarts are critical for production deployments. The pattern: send each worker a graceful disconnect signal, wait for in-flight requests to finish, then restart the worker.
import cluster from 'cluster';
import os from 'os';
const workers = new Map<number, cluster.Worker>();
function forkWorker(): void {
const worker = cluster.fork();
workers.set(worker.id, worker);
worker.on('exit', (code, signal) => {
workers.delete(worker.id);
console.log(`Worker ${worker.id} exited (code=${code}, signal=${signal})`);
if (!worker.exitedAfterDisconnect) {
forkWorker(); // Auto-restart on crash
}
});
}
async function gracefulRestart(): Promise<void> {
console.log('Starting graceful restart...');
const currentWorkers = [...workers.values()];
// Fork new workers first
for (let i = 0; i < currentWorkers.length; i++) {
forkWorker();
await new Promise<void>((resolve) => {
cluster.once('listening', resolve); // Wait until new worker is ready
});
// Then disconnect the old worker
currentWorkers[i].disconnect();
}
console.log('Graceful restart complete');
}
// Start workers
for (let i = 0; i < os.cpus().length; i++) {
forkWorker();
}
// Handle SIGUSR2 for zero-downtime restart
process.on('SIGUSR2', gracefulRestart);
// Handle SIGTERM for graceful shutdown
process.on('SIGTERM', () => {
console.log('Shutting down cluster...');
for (const worker of workers.values()) {
worker.disconnect();
}
});Trigger a graceful restart: kill -USR2 <primary-pid>. This pattern is used by PM2 internally.
Inter-Process Communication Between Workers
Workers are isolated processes, but the primary process can relay messages between them:
// Primary: relay messages between workers
cluster.on('message', (sender, message) => {
for (const [id, worker] of Object.entries(cluster.workers ?? {})) {
if (worker && worker.id !== sender.id) {
worker.send({ ...message, fromWorker: sender.id });
}
}
});
// In a worker: send and receive
process.send?.({ type: 'cache:invalidate', key: 'user:42' });
process.on('message', (msg: { type: string; key: string }) => {
if (msg.type === 'cache:invalidate') {
localCache.delete(msg.key);
}
});For complex inter-worker coordination (shared state, distributed locks), prefer Redis or a message broker over IPC — IPC scales poorly and adds primary process load.
Cluster vs PM2 vs Container Orchestration
| Approach | Best For | Key Trade-off |
|---|---|---|
| Node.js cluster | Single server, simple scaling | Manual restart/monitoring logic |
| PM2 | Production single-server deployment | External dependency, feature overlap with Docker |
| Docker + replicas | Multi-server, containerized apps | Requires orchestration (K8s, ECS) |
| Kubernetes HPA | Dynamic load-based scaling | Highest operational complexity |
For containerized environments, run one Node.js process per container and scale horizontally via container replicas. The cluster module adds complexity without benefit when the container scheduler handles distribution.
For bare-metal or VM deployments without containers, PM2 with pm2 start app.js -i max provides clustering, monitoring, and restart with zero custom code.
Common Mistakes
- Using cluster inside a Docker container — Docker containers typically have 1 vCPU. Forking multiple workers inside a single-CPU container adds process overhead without parallelism benefit.
- Shared in-memory state between workers — each worker has its own memory. In-memory caches (like local LRU maps) are not shared. Use Redis for shared cache.
- Not restarting crashed workers — without the restart logic in the
exithandler, a crashing worker permanently reduces capacity. - Synchronous startup code in workers — if
require('./app')blocks for several seconds, workers come online slowly and fail load balancer health checks. - Forgetting
exitedAfterDisconnect— this flag distinguishes an intentional disconnect (graceful restart) from a crash. Without this check, you restart workers that were intentionally stopped.
Best Practices
- Always check
worker.exitedAfterDisconnectbefore restarting on theexitevent. - Set worker count via an environment variable (
WORKERS) to tune for different server sizes without code changes. - Use PM2 in production unless you need custom restart logic — it handles clustering, logging, and monitoring.
- For Docker/Kubernetes, prefer one process per container and scale with replicas rather than using the cluster module.
- Monitor worker count with
Object.keys(cluster.workers).lengthand alert if it drops below expected. - Use
worker.send()sparingly — heavy IPC load is a bottleneck. Prefer shared external stores (Redis, PostgreSQL) for coordination.
Key Takeaways
- The Node.js cluster module forks multiple worker processes that share a single port, utilizing all available CPU cores for HTTP workloads.
- Cluster workers are separate OS processes with isolated heaps — a crashed worker does not affect siblings or the primary process.
- The OS distributes incoming connections across workers using round-robin scheduling by default.
- Zero-downtime restarts require forking new workers before disconnecting old ones, waiting for the
listeningevent before the transition. worker.exitedAfterDisconnectdistinguishes intentional disconnects from crashes — always check this before auto-restarting.- In-memory state is not shared between cluster workers; use Redis or a shared database for data that must be consistent across workers.
- In containerized environments, prefer one process per container with horizontal scaling over using the cluster module inside a container.
- PM2 with
-i maxprovides production-grade clustering without custom primary process code.
Advertisement