Node.js Cluster Module — Scale to Multiple CPU Cores 2026

Sanjeev SharmaSanjeev Sharma
6 min read

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

ApproachBest ForKey Trade-off
Node.js clusterSingle server, simple scalingManual restart/monitoring logic
PM2Production single-server deploymentExternal dependency, feature overlap with Docker
Docker + replicasMulti-server, containerized appsRequires orchestration (K8s, ECS)
Kubernetes HPADynamic load-based scalingHighest 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 exit handler, 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.exitedAfterDisconnect before restarting on the exit event.
  • 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).length and 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 listening event before the transition.
  • worker.exitedAfterDisconnect distinguishes 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 max provides production-grade clustering without custom primary process code.

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading