Node.js Clustering for Performance
Scale Node.js applications across CPU cores with the cluster module. Covers worker management, load balancing, zero-downtime restarts, and PM2.
What you'll learn
- ✓Why clustering is needed for Node.js performance
- ✓How the cluster module works under the hood
- ✓Managing worker processes and handling failures
- ✓Zero-downtime restarts and graceful shutdown
- ✓Using PM2 as a production process manager
Prerequisites
- •Node.js event loop and single-threaded model
- •Basic understanding of processes vs threads
- •HTTP server basics in Node.js
A single Node.js process runs on one CPU core. If your server has 8 cores, 7 of them sit idle. The cluster module lets you fork multiple worker processes that share the same server port, effectively multiplying your throughput by the number of CPU cores.
How clustering works
The cluster module creates one primary process and multiple worker processes. The primary process does not handle requests directly. Instead, it distributes incoming connections to workers using one of two strategies:
- Round-robin (default on Linux/macOS): The primary accepts connections and distributes them to workers in rotation.
- OS-level: The primary shares the socket with workers, and the OS decides which worker handles each connection.
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
console.log(`Primary ${process.pid} is running`);
console.log(`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 (${signal || code})`);
console.log('Starting a new worker...');
cluster.fork(); // Automatically restart crashed workers
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end(`Hello from worker ${process.pid}\n`);
}).listen(3000);
console.log(`Worker ${process.pid} started`);
}
Run this and you get one worker per CPU core, all sharing port 3000.
Communication between primary and workers
Workers and the primary communicate via IPC (inter-process communication):
if (cluster.isPrimary) {
const worker = cluster.fork();
// Send message to worker
worker.send({ type: 'config', data: { maxRetries: 3 } });
// Receive message from worker
worker.on('message', (msg) => {
console.log(`Message from worker ${worker.id}:`, msg);
});
} else {
// Receive message from primary
process.on('message', (msg) => {
if (msg.type === 'config') {
console.log('Received config:', msg.data);
}
});
// Send message to primary
process.send({ type: 'status', ready: true });
}
Broadcasting to all workers
if (cluster.isPrimary) {
function broadcast(message) {
for (const id in cluster.workers) {
cluster.workers[id].send(message);
}
}
// Example: notify all workers to clear cache
broadcast({ type: 'clearCache' });
}
Graceful shutdown
When deploying new code, you want to stop accepting new connections, finish processing current requests, then exit:
if (cluster.isPrimary) {
process.on('SIGTERM', () => {
console.log('Primary received SIGTERM, shutting down workers...');
for (const id in cluster.workers) {
cluster.workers[id].send({ type: 'shutdown' });
}
});
} else {
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('OK\n');
});
server.listen(3000);
process.on('message', (msg) => {
if (msg.type === 'shutdown') {
console.log(`Worker ${process.pid} shutting down...`);
// Stop accepting new connections
server.close(() => {
console.log(`Worker ${process.pid} closed all connections`);
process.exit(0);
});
// Force exit after timeout
setTimeout(() => {
console.error(`Worker ${process.pid} forced exit`);
process.exit(1);
}, 10000);
}
});
}
Zero-downtime restarts
Replace workers one at a time so the server is always available:
const cluster = require('cluster');
const http = require('http');
const os = require('os');
if (cluster.isPrimary) {
const numCPUs = os.cpus().length;
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
// Restart all workers one by one (triggered by SIGUSR2)
process.on('SIGUSR2', () => {
const workers = Object.values(cluster.workers);
console.log(`Rolling restart of ${workers.length} workers`);
function restartWorker(index) {
if (index >= workers.length) return;
const worker = workers[index];
console.log(`Restarting worker ${worker.process.pid}`);
// Fork a new worker first
const newWorker = cluster.fork();
newWorker.on('listening', () => {
// New worker is ready, kill the old one
worker.disconnect();
worker.on('disconnect', () => {
console.log(`Old worker ${worker.process.pid} disconnected`);
restartWorker(index + 1);
});
});
}
restartWorker(0);
});
// Replace crashed workers
cluster.on('exit', (worker, code) => {
if (code !== 0) {
console.log(`Worker ${worker.process.pid} crashed, replacing...`);
cluster.fork();
}
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end(`PID: ${process.pid}\n`);
}).listen(3000);
}
Trigger the restart with kill -SIGUSR2 <primary_pid>. Each worker is replaced one at a time, so there is no downtime.
Sticky sessions
If your application uses sessions stored in memory, a user’s requests must always go to the same worker. The default round-robin distribution breaks this. Solutions:
- Use a shared session store (Redis, database) — the best approach.
- Sticky sessions based on IP — route requests from the same IP to the same worker.
// Sticky sessions with the 'sticky-session' package
const sticky = require('sticky-session');
const http = require('http');
const server = http.createServer((req, res) => {
res.end(`Worker: ${process.pid}`);
});
if (!sticky.listen(server, 3000)) {
// Primary process
server.once('listening', () => {
console.log('Server started on port 3000');
});
} else {
// Worker process
console.log(`Worker ${process.pid} started`);
}
Monitoring worker health
if (cluster.isPrimary) {
setInterval(() => {
for (const id in cluster.workers) {
const worker = cluster.workers[id];
worker.send({ type: 'healthCheck' });
const timeout = setTimeout(() => {
console.error(`Worker ${worker.process.pid} unresponsive, killing...`);
worker.kill();
}, 5000);
worker.once('message', (msg) => {
if (msg.type === 'healthCheckResponse') {
clearTimeout(timeout);
}
});
}
}, 30000);
}
if (!cluster.isPrimary) {
process.on('message', (msg) => {
if (msg.type === 'healthCheck') {
process.send({
type: 'healthCheckResponse',
memory: process.memoryUsage(),
uptime: process.uptime(),
});
}
});
}
Using PM2 in production
For production deployments, PM2 handles clustering, monitoring, and process management:
# Start with clustering (auto-detect CPU count)
pm2 start app.js -i max
# Start with a specific number of workers
pm2 start app.js -i 4
# Zero-downtime restart
pm2 reload app.js
# Monitor
pm2 monit
# View logs
pm2 logs
# Save the process list for startup
pm2 save
pm2 startup
PM2 ecosystem file for configuration:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'api-server',
script: './src/server.js',
instances: 'max',
exec_mode: 'cluster',
max_memory_restart: '300M',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
// Graceful shutdown
kill_timeout: 5000,
listen_timeout: 10000,
// Auto-restart on failure
autorestart: true,
max_restarts: 10,
min_uptime: '10s',
}]
};
Cluster vs worker threads
| Aspect | Cluster | Worker Threads |
|---|---|---|
| Isolation | Separate processes | Shared process |
| Memory | Separate heaps | Can share memory |
| Use case | Scale HTTP servers | CPU-bound computation |
| Communication | IPC (serialization) | postMessage + SharedArrayBuffer |
| Port sharing | Built-in | Not applicable |
| Crash impact | Worker crash is isolated | Thread crash may affect process |
Use clustering to scale your HTTP server across cores. Use worker threads for CPU-intensive tasks within a single process.
Summary
The cluster module lets Node.js use all CPU cores by forking worker processes that share a server port. The primary process manages workers, handles crashes by forking replacements, and coordinates zero-downtime restarts. For production, PM2 provides a battle-tested process manager that handles all of this with simple commands. Use shared session stores (Redis) instead of sticky sessions when possible. Clustering scales your request-handling capacity linearly with the number of CPU cores.
Related articles
- Node.js Node.js Cluster Module for Multi-Core Scaling
Scale your Node.js application across all CPU cores using the cluster module. Covers load balancing, zero-downtime restarts, and production patterns.
- Node.js Node.js vs Deno vs Bun: JavaScript Runtimes Compared
Compare Node.js, Deno, and Bun on performance, security, compatibility, and developer experience. Choose the right JavaScript runtime for 2026.
- Node.js Corepack: Managing Package Managers in Node.js
Use Corepack to enforce consistent package manager versions across your team. Covers setup, configuration, and CI integration.
- Node.js Node.js diagnostics_channel: Built-in Observability
Use the diagnostics_channel API to add zero-overhead observability to your Node.js applications without modifying library code.