Skip to content
Codeloom
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.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How the cluster module distributes work across CPU cores
  • Round-robin vs OS-level load balancing
  • Zero-downtime restart patterns
  • When to use cluster vs worker threads vs a process manager

Prerequisites

  • Basic Node.js knowledge
  • Understanding of processes and networking
  • Familiarity with HTTP servers

A single Node.js process uses one CPU core. If your server has 8 cores, you are wasting 87% of your hardware. The cluster module solves this by forking your application into multiple worker processes that share the same server port. Each worker is a full Node.js instance with its own event loop and memory, and the primary process distributes incoming connections among them.

How clustering works

The cluster module uses a primary-worker architecture. The primary process forks worker processes, and incoming TCP connections are distributed to workers by the operating system or by Node.js itself.

import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';
import http from 'node:http';

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 (${signal || code})`);
    console.log('Starting a new worker...');
    cluster.fork(); // Auto-restart crashed workers
  });
} else {
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Handled by worker ${process.pid}\n`);
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

When you run this, the primary process forks workers equal to the number of CPU cores. All workers share port 8000. Incoming requests are distributed across workers automatically.

Load balancing strategies

Node.js supports two strategies for distributing connections:

Round-robin (default on Linux and macOS):

The primary process accepts connections and distributes them to workers in a round-robin fashion. This gives the most even distribution.

// Force round-robin scheduling
cluster.schedulingPolicy = cluster.SCHED_RR;

OS-level (default on Windows):

The OS kernel decides which worker handles each connection. This can lead to uneven distribution where some workers are overloaded while others sit idle.

// Let the OS decide
cluster.schedulingPolicy = cluster.SCHED_NONE;

For most applications, the default round-robin policy works well.

Communicating between primary and workers

Workers and the primary process communicate through IPC (inter-process communication) using message passing.

if (cluster.isPrimary) {
  const worker = cluster.fork();

  // Send a message to the worker
  worker.send({ type: 'config', port: 8000 });

  // Receive messages from workers
  worker.on('message', (msg) => {
    if (msg.type === 'stats') {
      console.log(`Worker ${worker.id} handled ${msg.requestCount} requests`);
    }
  });
} else {
  let requestCount = 0;

  process.on('message', (msg) => {
    if (msg.type === 'config') {
      console.log('Received config:', msg);
    }
  });

  // Report stats every 10 seconds
  setInterval(() => {
    process.send({ type: 'stats', requestCount });
  }, 10_000);

  http.createServer((req, res) => {
    requestCount++;
    res.end('OK');
  }).listen(8000);
}

Remember that workers have separate memory spaces. You cannot share variables between them. Use Redis, a database, or message passing for shared state.

Zero-downtime restarts

One of the most valuable patterns with clustering is restarting workers one at a time to deploy new code without dropping connections.

import cluster from 'node:cluster';
import { availableParallelism } from 'node:os';

if (cluster.isPrimary) {
  const numCPUs = availableParallelism();

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  async function rollingRestart() {
    const workers = Object.values(cluster.workers);

    for (const worker of workers) {
      // Fork a new worker before killing the old one
      const replacement = cluster.fork();

      await new Promise((resolve) => {
        replacement.on('listening', resolve);
      });

      // New worker is ready — disconnect the old one
      worker.disconnect();

      await new Promise((resolve) => {
        worker.on('exit', resolve);
      });

      console.log(`Replaced worker ${worker.process.pid} with ${replacement.process.pid}`);
    }

    console.log('Rolling restart complete');
  }

  // Trigger restart with SIGUSR2
  process.on('SIGUSR2', () => {
    console.log('Received SIGUSR2 — starting rolling restart');
    rollingRestart();
  });

  cluster.on('exit', (worker, code) => {
    if (code !== 0 && !worker.exitedAfterDisconnect) {
      console.log(`Worker ${worker.process.pid} crashed — restarting`);
      cluster.fork();
    }
  });
}

Send kill -USR2 <primary_pid> to trigger the restart. Each worker is replaced one at a time, so the server never has zero capacity.

Graceful shutdown with clustering

Workers should finish processing in-flight requests before exiting:

if (!cluster.isPrimary) {
  const server = http.createServer((req, res) => {
    // Simulate some work
    setTimeout(() => {
      res.end('Done');
    }, 1000);
  });

  server.listen(8000);

  process.on('message', (msg) => {
    if (msg === 'shutdown') {
      console.log(`Worker ${process.pid} shutting down gracefully`);

      server.close(() => {
        console.log(`Worker ${process.pid} closed all connections`);
        process.exit(0);
      });

      // Force exit after 10 seconds
      setTimeout(() => {
        console.log(`Worker ${process.pid} forced exit`);
        process.exit(1);
      }, 10_000);
    }
  });
}

Sticky sessions for WebSocket

If your application uses WebSockets or any stateful protocol, you need sticky sessions so that all requests from the same client go to the same worker.

import cluster from 'node:cluster';
import net from 'node:net';
import { createHash } from 'node:crypto';

if (cluster.isPrimary) {
  const workers = [];

  for (let i = 0; i < 4; i++) {
    workers.push(cluster.fork());
  }

  const server = net.createServer({ pauseOnConnect: true }, (connection) => {
    // Hash the IP to pick a consistent worker
    const ip = connection.remoteAddress;
    const hash = createHash('md5').update(ip).digest();
    const workerIndex = hash.readUInt32BE(0) % workers.length;

    workers[workerIndex].send('sticky-session:connection', connection);
  });

  server.listen(8000);
}

Cluster vs worker threads vs process managers

FeatureClusterWorker ThreadsPM2
Separate memoryYesShared possibleYes
Share server portYesNoYes
CPU parallelismYesYesYes
Zero-downtime restartManualN/ABuilt-in
MonitoringManualManualBuilt-in

Use cluster when you need to scale an HTTP server across cores and want full control. Use worker threads when you need to offload CPU-intensive computation from the main thread. Use PM2 when you want clustering with built-in monitoring, log management, and deployment features without writing the infrastructure yourself.

Production checklist

Before deploying a clustered Node.js application:

  1. Set the number of workers to availableParallelism() or slightly less to leave room for the OS
  2. Always auto-restart crashed workers in the primary process
  3. Implement graceful shutdown with a timeout
  4. Use shared state storage (Redis) instead of in-memory state
  5. Add health checks per worker
  6. Monitor individual worker memory and CPU usage
  7. Test your rolling restart procedure before deploying

Key takeaways

The cluster module is the simplest way to use all CPU cores with Node.js. It works best for stateless HTTP servers where each request is independent. For stateful applications, add sticky sessions. For CPU-bound work within a single request, combine clustering with worker threads. And in production, consider whether PM2 gives you what you need without custom code.