Skip to content
Codeloom
Node.js

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.

·6 min read · By Codeloom
Advanced 13 min read

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:

  1. Round-robin (default on Linux/macOS): The primary accepts connections and distributes them to workers in rotation.
  2. 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:

  1. Use a shared session store (Redis, database) — the best approach.
  2. 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

AspectClusterWorker Threads
IsolationSeparate processesShared process
MemorySeparate heapsCan share memory
Use caseScale HTTP serversCPU-bound computation
CommunicationIPC (serialization)postMessage + SharedArrayBuffer
Port sharingBuilt-inNot applicable
Crash impactWorker crash is isolatedThread 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.