Node.js 65 🟢 Multi-Core Scaling and Process Load Balancing with the Cluster Module
A Node.js process runs on a single CPU core. This is not a limitation of the language but a consequence of its architecture: the event loop runs on one thread, and while that thread handles thousands of concurrent I/O operations efficiently, it cannot execute JavaScript in parallel. On a machine with eight cores, a single Node.js process uses one of them. The other seven sit idle. The cluster module exists to change that.
The module works by forking worker processes that share the same server port . One primary process coordinates the workers, accepts incoming connections, and distributes them across the workers. Each worker is a full Node.js process with its own event loop, its own memory, and its own V8 instance. From the operating system’s perspective, the machine is running multiple independent Node.js processes. From the application’s perspective, they all serve the same HTTP endpoint on the same port.
This chapter covers the cluster module’s architecture: how the primary and workers interact, how connections are distributed, how workers are managed and restarted, and how to structure a clustered application for reliability.
Key point: The cluster module forks worker processes that share a server port; the primary process accepts connections and distributes them to workers using round-robin scheduling by default, allowing a Node.js application to use all available CPU cores.
Why the cluster module exists
The single-core ceiling. Node.js was designed around the event loop, which excels at I/O-bound work. But a single event loop can only execute one JavaScript instruction at a time. On a multi-core server, that leaves most of the CPU idle. The cluster module lifts this ceiling by running multiple event loops—one per worker process—so the application can handle more concurrent requests in parallel .
The shared port problem. If you simply started multiple Node.js processes with server.listen(8000), the second process would fail with EADDRINUSE because the port is already bound. The cluster module solves this by having the primary process own the listening socket and distribute accepted connections to workers . Workers never actually bind the port themselves; they receive connections through the IPC channel.
The failover problem. A single Node.js process that crashes takes the entire application down. With cluster, if a worker crashes, the primary process can fork a replacement, and the application continues serving requests through the remaining workers . The crash is contained to one worker, not the whole service.
The deployment problem. Without clustering, deploying a new version means stopping the old process and starting the new one—a gap where requests fail. With cluster, you can perform a rolling restart: start new workers, wait for them to become ready, then gracefully shut down the old ones . Zero-downtime deployments become possible without external orchestration.
The platform difference. The cluster module has two scheduling strategies: round-robin, which the primary manages, and the operating system’s default distribution. Round-robin is the default on all platforms except Windows . The choice matters because the OS strategy has been observed to produce highly unbalanced loads, with over 70% of connections landing on two processes out of eight .
a. The primary process and worker forking
The cluster module uses a simple branch: the primary process coordinates, and the workers serve. The cluster.isPrimary (formerly isMaster) boolean distinguishes the two .
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
const numCPUs = availableParallelism();
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died`);
cluster.fork();
});
} else {
http.createServer((req, res) => {
res.writeHead(200);
res.end('hello world\n');
}).listen(8000);
console.log(`Worker ${process.pid} started`);
}
The availableParallelism() function returns the number of CPU cores available to the process, which is more accurate than os.cpus().length in containerized environments where CPU limits may differ from the physical core count .
The primary process forks one worker per CPU core. Each worker runs the same file but takes the else branch because isPrimary is false. The workers call listen(8000), but the port is not actually bound by them—the cluster module intercepts the listen call and routes it through the primary .
When a worker exits, the 'exit' event fires on the primary. The code above forks a replacement immediately. In production, you might add a delay to avoid a crash loop .
b. Round-robin scheduling and the listening handle
The cluster module’s default distribution strategy is round-robin. The primary process owns the listening socket, accepts incoming connections, and passes them to workers in sequence .
The mechanism is subtle. When a worker calls server.listen(8000), the cluster module intercepts the call. Instead of binding the port, the worker sends a message to the primary requesting the server handle. The primary creates the actual listening socket and sends the handle back to the worker through the IPC channel . The worker then uses that handle to accept connections, but the primary has already set up the distribution logic.
In round-robin mode, the primary’s onconnection handler is replaced with a distribute function. When a new connection arrives, this function picks the next worker in rotation and sends the connection to it . The workers never compete for connections; the primary decides who gets what.
The alternative is SCHED_NONE, which leaves distribution to the operating system. In this mode, the primary creates the listening socket and sends it to the workers, and each worker accepts connections directly. The OS decides which worker gets which connection based on its own scheduler . This is theoretically more efficient because it avoids the primary as a bottleneck, but in practice it produces unbalanced loads. Node.js documentation notes that over 70% of connections have been observed landing on two out of eight processes .
The scheduling policy is a global setting, frozen once the first worker is spawned or setupPrimary() is called . It can be set via the NODE_CLUSTER_SCHED_POLICY environment variable with values 'rr' or 'none' .
c. Worker lifecycle, graceful shutdown, and zero-downtime reloads
A cluster worker is a child_process.fork() instance with additional cluster-specific properties. Each worker has a unique id, accessible as worker.id in the primary and cluster.worker.id in the worker . The cluster.workers object in the primary is a hash of all active workers keyed by their IDs .
Workers emit events that the primary can listen for: 'online' when the worker is running, 'listening' when it has started listening, 'disconnect' when the IPC channel closes, and 'exit' when the process ends . The 'exit' event provides the exit code and signal, which distinguishes between a voluntary shutdown and a crash .
Graceful shutdown is handled by worker.disconnect(). This closes the worker’s servers, waits for existing connections to close, and then disconnects the IPC channel. The worker then exits naturally . The exitedAfterDisconnect property tells the primary whether the exit was expected .
For zero-downtime reloads, the pattern is:
- Fork a new worker.
- Wait for it to emit
'listening'. - Disconnect the old worker and let it drain.
- Repeat for each worker.
Libraries like @florajs/cluster and @ynode/cluster implement this pattern with additional features like rollback on failure and aggregated status tracking . The core idea is the same: never remove capacity before replacement capacity is ready.
The cluster.disconnect() method calls disconnect() on every worker, allowing the primary to exit gracefully once all workers are done . It takes an optional callback that fires when all workers have disconnected.
Complete Example Session
The following session builds a clustered HTTP server with round-robin distribution, worker restart, and a simple zero-downtime reload.
// ============================================
// PART 1: IMPORTS AND CONFIGURATION
// ============================================
const cluster = require('node:cluster');
const http = require('node:http');
const { availableParallelism } = require('node:os');
const NUM_WORKERS = availableParallelism();
const PORT = 8000;
// ============================================
// PART 2: PRIMARY PROCESS LOGIC
// ============================================
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} running with ${NUM_WORKERS} workers`);
// Fork workers
for (let i = 0; i < NUM_WORKERS; i++) {
cluster.fork();
}
// ============================================
// PART 3: WORKER EXIT HANDLING
// ============================================
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} exited (${signal || code})`);
if (!worker.exitedAfterDisconnect) {
console.log('Worker crashed, forking replacement');
cluster.fork();
}
});
// ============================================
// PART 4: WORKER ONLINE EVENT
// ============================================
cluster.on('online', (worker) => {
console.log(`Worker ${worker.process.pid} is online`);
});
// ============================================
// PART 5: LISTENING EVENT
// ============================================
cluster.on('listening', (worker, address) => {
console.log(`Worker ${worker.process.pid} listening on ${address.address}:${address.port}`);
});
// ============================================
// PART 6: ZERO-DOWNTIME RELOAD
// ============================================
process.on('SIGHUP', () => {
console.log('SIGHUP received, starting reload');
const workerIds = Object.keys(cluster.workers);
let index = 0;
function reloadNext() {
if (index >= workerIds.length) return;
const oldWorker = cluster.workers[workerIds[index]];
if (!oldWorker) {
index++;
return reloadNext();
}
const newWorker = cluster.fork();
newWorker.on('listening', () => {
oldWorker.disconnect();
oldWorker.on('disconnect', () => {
index++;
reloadNext();
});
});
}
reloadNext();
});
} else {
// ============================================
// PART 7: WORKER HTTP SERVER
// ============================================
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end(`Worker ${process.pid} handled request\n`);
});
// ============================================
// PART 8: WORKER LISTEN
// ============================================
server.listen(PORT, () => {
console.log(`Worker ${process.pid} listening on ${PORT}`);
});
// ============================================
// PART 9: GRACEFUL SHUTDOWN IN WORKER
// ============================================
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
});
}
// ============================================
// PART 10: STARTUP MESSAGE
// ============================================
console.log(`Process ${process.pid} started`);
The ten parts cover imports, primary process forking, exit handling with crash detection, online and listening events, a SIGHUP-triggered zero-downtime reload, the worker’s HTTP server, listening, graceful shutdown, and startup. What the session demonstrates is that cluster is not a library you install—it is a pattern built into Node.js that turns one process into a pool of processes sharing a single port.
Quick Reference
Core API
| Item | Purpose |
|---|---|
cluster.isPrimary | true in primary process |
cluster.fork() | Spawn a worker |
cluster.workers | Hash of active workers |
cluster.worker | Current worker reference |
worker.id | Unique worker identifier |
worker.process.pid | Worker’s OS process ID |
worker.disconnect() | Graceful shutdown |
cluster.disconnect() | Shut down all workers |
Events
| Event | Emitted On | Meaning |
|---|---|---|
'online' | cluster | Worker is running |
'listening' | cluster | Worker started listening |
'exit' | cluster | Worker process ended |
'disconnect' | cluster/worker | IPC channel closed |
'message' | cluster | Worker sent a message |
Scheduling Policies
| Policy | Constant | Default | Behavior |
|---|---|---|---|
| Round-robin | SCHED_RR | Non-Windows | Primary distributes connections |
| OS default | SCHED_NONE | Windows | Workers accept directly |
Worker Properties
| Property | Meaning |
|---|---|
worker.exitedAfterDisconnect | true if exit was voluntary |
worker.isConnected() | IPC channel is open |
worker.isDead() | Process has terminated |
Best Practices
✅ Do This:
for (let i = 0; i < availableParallelism(); i++) cluster.fork(); // ✅ one per core
if (!worker.exitedAfterDisconnect) cluster.fork(); // ✅ restart crashes
worker.disconnect(); // ✅ graceful shutdown
cluster.on('exit', (worker, code) => log(code)); // ✅ monitor exits
❌ Don’t Do This:
cluster.fork(); cluster.fork(); cluster.fork(); // ❌ more than cores
// no exit handler // ❌ silent worker death
worker.kill('SIGKILL'); // ❌ ungraceful
// workers > CPU count // ❌ context switching overhead
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
EADDRINUSE | Workers binding port directly | Let primary handle listen |
| Unbalanced load | OS scheduling on Windows | Use round-robin or Linux |
| Worker crash loop | Restarting too fast | Add delay or circuit breaker |
| Stale connections on reload | disconnect() waits for drain | Send shutdown message to close idle connections |
| Memory leak in workers | Workers not restarted | Periodic rolling restart |
Real-World Examples
1. One worker per core
for (let i = 0; i < availableParallelism(); i++) cluster.fork();
2. Crash restart with delay
cluster.on('exit', (worker) => {
if (!worker.exitedAfterDisconnect) {
setTimeout(() => cluster.fork(), 1000);
}
});
3. Graceful shutdown
worker.disconnect();
worker.on('disconnect', () => worker.kill());
4. Zero-downtime reload
const newWorker = cluster.fork();
newWorker.on('listening', () => oldWorker.disconnect());
5. Check worker count
Object.keys(cluster.workers).length
6. Restart a specific worker
const worker = cluster.workers[id];
worker.disconnect();
cluster.fork();
7. Custom scheduling
cluster.schedulingPolicy = cluster.SCHED_RR;
8. Worker messaging
worker.send({ cmd: 'shutdown' });
9. Aggregate status from workers
for (const id in cluster.workers) {
cluster.workers[id].on('message', (msg) => aggregate(msg));
}
10. Disconnect all workers
cluster.disconnect(() => console.log('all workers done'));
Visual
Cluster Architecture
┌──────────────────────────────────────────────┐
│ PRIMARY PROCESS │
│ ┌────────────────────────────────────────┐ │
│ │ Owns listening socket (port 8000) │ │
│ │ Accepts incoming connections │ │
│ │ Distributes to workers (round-robin) │ │
│ └────────────────┬───────────────────────┘ │
│ │ │
│ IPC channel (cluster) │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌───────┐ ┌───────┐ ┌───────┐ │
│ │Worker1│ │Worker2│ │Worker3│ │
│ │Event │ │Event │ │Event │ │
│ │loop │ │loop │ │loop │ │
│ └───────┘ └───────┘ └───────┘ │
│ Each worker is a separate Node.js process │
└──────────────────────────────────────────────┘
Round-Robin Distribution
┌──────────────────────────────────────────────┐
│ INCOMING CONNECTIONS │
│ C1 C2 C3 C4 C5 C6 │
│ │ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ ┌────────────────────────────────────────┐ │
│ │ PRIMARY (round-robin) │ │
│ │ C1→W1 C2→W2 C3→W3 C4→W1 ... │ │
│ └────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ W1 W2 W3 │
│ handles handles handles │
│ C1,C4 C2,C5 C3,C6 │
└──────────────────────────────────────────────┘
Worker Lifecycle
┌──────────────────────────────────────────────┐
│ cluster.fork() │
│ │ │
│ ▼ │
│ FORKED ──▶ ONLINE ──▶ LISTENING ──▶ SERVING │
│ │
│ On crash: │
│ SERVING ──▶ EXIT (unexpected) ──▶ FORKED │
│ │
│ On shutdown: │
│ SERVING ──▶ DISCONNECT ──▶ EXIT (voluntary) │
└──────────────────────────────────────────────┘
Zero-Downtime Reload Sequence
┌──────────────────────────────────────────────┐
│ BEFORE: [W1] [W2] [W3] [W4] (v1.0) │
│ │
│ STEP 1: Fork new worker │
│ [W1] [W2] [W3] [W4] [W5(v2.0)] │
│ │
│ STEP 2: Wait for W5 'listening' │
│ │
│ STEP 3: Disconnect W1 (drain) │
│ [W2] [W3] [W4] [W5(v2.0)] │
│ │
│ REPEAT until all workers are v2.0 │
│ │
│ AFTER: [W5] [W6] [W7] [W8] (v2.0) │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Module | node:cluster |
| Primary role | Owns port, distributes connections |
| Worker role | Serves requests |
| Default scheduling | Round-robin (non-Windows) |
| CPU detection | availableParallelism() |
| Worker creation | cluster.fork() |
| Graceful shutdown | worker.disconnect() |
| Crash detection | worker.exitedAfterDisconnect |
| Zero-downtime | Rolling fork + disconnect |
Key takeaways:
- Cluster forks worker processes that share a server port. The primary owns the listening socket and distributes connections; workers never bind the port directly .
- Round-robin is the default scheduling policy. The primary rotates through workers, avoiding the unbalanced distribution that the OS scheduler produces .
- One worker per CPU core is the standard.
availableParallelism()returns the correct core count for the environment . - Worker crashes are contained. The primary can detect an unexpected exit and fork a replacement, keeping the service available .
- Graceful shutdown uses
disconnect(). It closes servers, waits for connections to drain, then closes the IPC channel . - Zero-downtime reloads are possible. Fork a replacement, wait for it to listen, then disconnect the old worker .
- Cluster is not a load balancer in the traditional sense. It distributes connections at accept time; for sticky sessions or application-level routing, additional tooling is needed .
Remember: The cluster module turns a single-threaded Node.js application into a multi-process application that uses every CPU core. The primary process owns the port and distributes connections; the workers run the event loop and serve requests. Round-robin scheduling prevents the unbalanced loads that occur when the OS decides which process gets which connection. Worker crashes are contained and recovered by forking replacements. Graceful shutdown and rolling reloads keep the service available during deployments. For any production Node.js HTTP server, cluster is the first step toward using the hardware you are paying for.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!