DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

Node.js Cluster Module Guide: Scaling Apps Across CPU Cores

Node.js is known for its efficient event-driven architecture, but a single Node.js process normally runs JavaScript on one CPU core. As traffic grows, that can become a bottleneck when an application needs to handle more concurrent requests.

The built-in Cluster module provides a practical way to run multiple Node.js worker processes that can share a server port. This allows an application to take advantage of multiple CPU cores while keeping the familiar Node.js HTTP programming model.

In this guide, we will build a runnable clustered HTTP server, understand how the primary and worker processes communicate, and explore graceful worker management. The example also demonstrates worker lifecycle events, request handling, and basic monitoring through detailed console output.

Understanding the Node.js Cluster Module

The Cluster module allows a primary process to create multiple worker processes using Node.js's built-in child-process capabilities. Each worker runs independently with its own JavaScript heap and event loop, while the cluster infrastructure can distribute incoming connections across workers listening on the same port.

A common pattern is to use the number of available CPU cores as the worker count. The primary process creates the workers, monitors their lifecycle, and can replace workers when they exit unexpectedly. Worker processes focus on application work, such as handling HTTP requests, while the primary process coordinates the cluster.

The following example creates one worker per available CPU core, although the count can be limited through the CLUSTER_WORKERS environment variable. Each worker starts an HTTP server on the same port, reports its process ID, and responds with useful runtime information.

The example also demonstrates graceful shutdown behavior. When a worker exits, the primary process logs the event and creates a replacement worker, while SIGINT allows the application to shut down all workers cleanly instead of terminating them abruptly.

It is important to remember that cluster workers are separate processes rather than threads sharing the same JavaScript memory. In-memory variables, caches, and application state are therefore not automatically synchronized between workers. For shared state, production applications commonly use external systems such as Redis or a database.

const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');

const PORT = Number(process.env.PORT) || 3000;
const requestedWorkers = Number(process.env.CLUSTER_WORKERS);
const cpuCount = os.availableParallelism();
const workerCount = Number.isInteger(requestedWorkers) && requestedWorkers > 0
  ? Math.min(requestedWorkers, cpuCount)
  : cpuCount;

// The primary process is responsible for creating and monitoring workers.
if (cluster.isPrimary) {
  console.log('='.repeat(60));
  console.log('[PRIMARY] Node.js Cluster example starting...');
  console.log(`[PRIMARY] Process ID: ${process.pid}`);
  console.log(`[PRIMARY] Available CPU parallelism: ${cpuCount}`);
  console.log(`[PRIMARY] Workers to start: ${workerCount}`);
  console.log(`[PRIMARY] Shared HTTP port: ${PORT}`);
  console.log('='.repeat(60));

  // Create the requested number of independent worker processes.
  for (let index = 0; index < workerCount; index += 1) {
    const worker = cluster.fork();
    console.log(`[PRIMARY] Worker ${worker.id} created with PID ${worker.process.pid}`);
  }

  // Track workers that become online and ready to accept requests.
  cluster.on('online', (worker) => {
    console.log(`[PRIMARY] Worker ${worker.id} is online (PID ${worker.process.pid})`);
  });

  // Workers can send messages to the primary process.
  cluster.on('message', (worker, message) => {
    console.log(`[PRIMARY] Message from worker ${worker.id}:`, message);
  });

  // Replace a worker if it exits unexpectedly.
  cluster.on('exit', (worker, code, signal) => {
    const reason = signal || `exit code ${code}`;
    console.log(`[PRIMARY] Worker ${worker.id} stopped: ${reason}`);

    if (!isShuttingDown) {
      console.log('[PRIMARY] Starting a replacement worker...');
      const replacement = cluster.fork();
      console.log(`[PRIMARY] Replacement worker ${replacement.id} created with PID ${replacement.process.pid}`);
    }
  });

  let isShuttingDown = false;

  // Gracefully stop every worker when the process receives Ctrl+C.
  process.on('SIGINT', () => {
    if (isShuttingDown) return;

    isShuttingDown = true;
    console.log('\n[PRIMARY] Shutdown signal received. Stopping workers...');

    for (const worker of Object.values(cluster.workers)) {
      worker.send({ type: 'shutdown' });
      worker.disconnect();
    }

    setTimeout(() => {
      console.log('[PRIMARY] Shutdown complete.');
      process.exit(0);
    }, 1000);
  });
} else {
  // Every worker has its own event loop, memory, and process ID.
  const workerId = cluster.worker.id;
  const workerPid = process.pid;
  let requestCount = 0;

  console.log(`[WORKER ${workerId}] Starting worker process with PID ${workerPid}`);

  const server = http.createServer((req, res) => {
    requestCount += 1;

    console.log(`[WORKER ${workerId}] Request #${requestCount}: ${req.method} ${req.url}`);

    // Build a small JSON response showing which worker handled the request.
    const response = {
      message: 'Hello from a Node.js cluster worker',
      workerId,
      processId: workerPid,
      requestNumber: requestCount,
      cpuCount,
      timestamp: new Date().toISOString()
    };

    res.writeHead(200, {
      'Content-Type': 'application/json',
      'Cache-Control': 'no-store'
    });

    res.end(JSON.stringify(response, null, 2));
  });

  // Workers listen on the same port through the cluster infrastructure.
  server.listen(PORT, () => {
    console.log(`[WORKER ${workerId}] HTTP server listening on port ${PORT}`);

    // Notify the primary process that this worker is ready.
    process.send?.({
      type: 'ready',
      workerId,
      processId: workerPid,
      port: PORT
    });
  });

  // Handle a shutdown message sent by the primary process.
  process.on('message', (message) => {
    if (message?.type !== 'shutdown') return;

    console.log(`[WORKER ${workerId}] Graceful shutdown requested.`);

    server.close(() => {
      console.log(`[WORKER ${workerId}] HTTP server closed.`);
      process.exit(0);
    });
  });

  // Handle worker-level errors without silently failing.
  server.on('error', (error) => {
    console.error(`[WORKER ${workerId}] Server error:`, error.message);
  });
}

Enter fullscreen mode Exit fullscreen mode

Conclusion

The Node.js Cluster module is useful when a CPU-bound workload or high request volume makes a single process insufficient. By running multiple worker processes, an application can use more available CPU resources while maintaining a straightforward HTTP server architecture.

However, clustering does not automatically solve every scalability problem. Workers have isolated memory, so shared sessions, caches, queues, and other state should generally live in external infrastructure when consistency across workers is required.

For production systems, combine clustering with graceful shutdowns, health checks, process monitoring, centralized logging, and a reverse proxy or load balancer when appropriate. Understanding these boundaries helps you use the Cluster module as part of a broader Node.js scalability strategy rather than treating it as a complete scaling solution.

Top comments (0)