Cluster Module & PM2

Difficulty: Advanced

Question

How do you utilize multiple CPU cores in Node.js? Explain the cluster module and PM2.

Answer

Node.js runs on a single thread by default, using only one CPU core. The cluster module allows you to fork multiple worker processes that share the same server port, utilizing all available CPU cores. The master process manages workers and distributes connections.

PM2 is a production process manager that handles clustering, auto-restart on crash, log management, monitoring, and zero-downtime reloads. It is the standard way to run Node.js in production. PM2's cluster mode is simpler than using the cluster module directly.

With cluster mode, each worker is a separate Node.js process with its own memory space and event loop. They share the TCP port through the OS. The master process distributes incoming connections using round-robin (default on Linux) or OS-level load balancing.

Workers are independent: if one crashes, others continue serving requests. The master can restart crashed workers automatically. This provides fault tolerance and better resource utilization. However, workers do not share memory, so shared state (sessions, cache) must use Redis or similar external stores.

Code examples

Node.js Cluster Module

const cluster = require('cluster');
const os = require('os');
const express = require('express');

const numCPUs = os.cpus().length;

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} starting ${numCPUs} workers`);

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

  // Restart crashed workers
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died (${signal || code})`);
    console.log('Starting replacement worker...');
    cluster.fork();
  });

  // Track workers
  cluster.on('online', (worker) => {
    console.log(`Worker ${worker.process.pid} is online`);
  });

} else {
  // Workers share the same port
  const app = express();

  app.get('/', (req, res) => {
    res.json({
      pid: process.pid,
      uptime: process.uptime(),
    });
  });

  app.listen(3000, () => {
    console.log(`Worker ${process.pid} listening on port 3000`);
  });
}

The primary process forks workers equal to CPU count. Each worker runs the Express app. If a worker crashes, the primary forks a replacement.

PM2 Configuration and Commands

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'my-api',
    script: './dist/index.js',
    instances: 'max',        // Use all CPU cores
    exec_mode: 'cluster',    // Enable cluster mode
    max_memory_restart: '500M',
    env: {
      NODE_ENV: 'development',
      PORT: 3000,
    },
    env_production: {
      NODE_ENV: 'production',
      PORT: 3000,
    },
    // Log configuration
    error_file: './logs/error.log',
    out_file: './logs/output.log',
    merge_logs: true,
    log_date_format: 'YYYY-MM-DD HH:mm:ss',
    // Auto-restart
    watch: false,
    max_restarts: 10,
    restart_delay: 4000,
  }],
};

// PM2 Commands:
// pm2 start ecosystem.config.js --env production
// pm2 reload my-api          # Zero-downtime restart
// pm2 scale my-api +2        # Add 2 more workers
// pm2 monit                  # Real-time monitoring
// pm2 logs my-api            # View logs
// pm2 save                   # Save process list
// pm2 startup                # Auto-start on boot

PM2's ecosystem file configures clustering, memory limits, logs, and environment variables. 'reload' provides zero-downtime restarts by restarting workers one at a time.

Sticky Sessions and Shared State

// Problem: workers don't share memory
// In-memory sessions/cache won't work with clustering

// BAD: in-memory session (each worker has different sessions)
const sessions = new Map(); // Only exists in THIS worker's memory

// GOOD: Redis for shared state across workers
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const Redis = require('ioredis');

const redis = new Redis(process.env.REDIS_URL);

app.use(session({
  store: new RedisStore({ client: redis }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: { secure: true, httpOnly: true, maxAge: 86400000 },
}));

// For Socket.IO with clustering
const { createAdapter } = require('@socket.io/redis-adapter');
const pubClient = new Redis(process.env.REDIS_URL);
const subClient = pubClient.duplicate();

io.adapter(createAdapter(pubClient, subClient));
// Now Socket.IO events are shared across all workers

Workers are separate processes with separate memory. Use Redis for sessions, cache, and Socket.IO state to share across workers.

Key points

Concepts covered

Cluster, PM2, Load Balancing, Multi-Core, Process Management