DevLift
Back to Blog

Node.js Clustering and Child Processes: Scale Across All CPU Cores

Node.js runs on one CPU core by default. The cluster module lets you fork N workers across all cores — here's the pattern, graceful shutdown, IPC, and when PM2 makes it unnecessary.

Admin
August 3, 20266 min read6 views

Node.js Clustering and Child Processes: Scale Across All CPU Cores

Your Node.js server is probably running on a single CPU core right now. On an 8-core machine, that means 7 cores are sitting idle while your event loop queues up requests. The cluster module is how you fix that — and it's been in Node.js since v0.6.

Let's talk about when to use it, when to use child_process directly, and when PM2 handles this for you so you don't have to write any of it by hand.

The Problem: One Process, One Core

Node.js is single-threaded by design. The event loop is great for I/O-bound work (database queries, HTTP calls, file reads) but it fundamentally can't use more than one CPU core per process. A CPU-bound request — say, parsing a large JSON payload or running a complex computation — blocks the entire event loop until it's done.

Here's the classic footgun:

import express from "express";
import os from "os";
 
const app = express();
 
app.get("/status", (req, res) => {
  // This blocks the event loop for every other request while running
  const result = heavyComputation(); 
  res.json({ result });
});
 
app.listen(3000, () => {
  // You've got os.cpus().length cores, but you're using exactly one.
  console.log(`Running on 1 of ${os.cpus().length} cores`);
});

Even for I/O-bound servers, a single process has a throughput ceiling. One process can only hold so many concurrent connections before latency climbs, and a single blocking computation stalls all of them. Clustering gives you N independent event loops instead of one — how much extra throughput that buys depends entirely on what your handlers are waiting for, which is the point of the last section of this article.

The Cluster Module Pattern

The cluster module lets a primary process fork N worker processes, all listening on the same port. The OS (or the primary process, via round-robin) distributes incoming connections across workers.

import cluster from "node:cluster";
import os from "node:os";
import process from "node:process";
 
const NUM_WORKERS = os.availableParallelism?.() ?? 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();
  }
 
  // Restart a worker if it crashes
  cluster.on("exit", (worker, code, signal) => {
    if (!worker.exitedAfterDisconnect) {
      console.warn(`Worker ${worker.process.pid} died (${signal ?? code}). Restarting...`);
      cluster.fork();
    }
  });
} else {
  // Worker: each runs its own event loop
  startServer();
}

Each worker runs the same server.ts file. They share the port binding via an IPC channel with the primary, but each has its own memory space, module cache, and event loop. Crash one worker and the rest keep serving traffic.

The actual server code is unchanged:

// server.ts — runs in each worker process
import express from "express";
import type { Server } from "node:http";
import process from "node:process";
 
// Hold onto what listen() returns. Every graceful-shutdown snippet below needs
// it, and app.listen() is the only place you get it.
let server: Server | undefined;
 
function startServer() {
  const app = express();
 
  app.get("/health", (req, res) => {
    res.json({ pid: process.pid, status: "ok" });
  });
 
  server = app.listen(3000, () => {
    console.log(`Worker ${process.pid} listening on :3000`);
  });
}
 
export { startServer, server };

Hit /health a few times and you'll see different PIDs — that's your load balancer working.

Rendering diagram...

Graceful Shutdown

The naive pattern above has a problem: when you deploy a new version, you need to restart workers without dropping in-flight requests. Here's a zero-downtime reload using IPC messaging:

// In the primary process
import cluster, { type Worker } from "node:cluster";
 
if (cluster.isPrimary) {
  const workers = new Set<Worker>();
  const retiring = new Set<Worker>();   // workers we asked to leave
 
  for (let i = 0; i < NUM_WORKERS; i++) {
    workers.add(cluster.fork());
  }
 
  cluster.on("fork", (worker) => workers.add(worker));
  cluster.on("exit", (worker) => {
    workers.delete(worker);
    // A worker we retired on purpose is replaced by the rolling restart below.
    // Only replace it here if it died on its own.
    if (retiring.delete(worker)) return;
    workers.add(cluster.fork());
  });
 
  // Rolling restart: cycle through workers one at a time
  process.on("SIGUSR2", () => {
    const workerArray = Array.from(workers);
    let index = 0;
 
    function restartNext() {
      if (index >= workerArray.length) return;
      const worker = workerArray[index++];
      retiring.add(worker);
 
      // Give it 10s to drain, then force kill
      const force = setTimeout(() => worker.kill(), 10_000);
 
      worker.on("exit", () => {
        clearTimeout(force);
        cluster.fork().on("listening", restartNext);
      });
 
      worker.send("shutdown");
    }
 
    restartNext();
  });
}
// In each worker — `server` is what app.listen() returned
process.on("message", (msg) => {
  if (msg === "shutdown") {
    server.close(() => process.exit(0)); // Wait for connections to drain
  }
});

Send kill -USR2 <primary-pid> and workers restart one-by-one. New requests go to the already-running workers while each old worker drains its connections.

🚨

That retiring set is not defensive programming, it's a bug fix. The obvious version of this code — a crash-restart handler guarded by if (!worker.exitedAfterDisconnect) plus a per-worker exit handler in the restart loop — forks twice for every worker you retire. exitedAfterDisconnect is only true when the primary called worker.kill() or worker.disconnect(); a worker that exits itself via process.exit(0) after draining doesn't set it, so the crash handler decides it crashed and replaces it, and then the restart loop replaces it again.

Running it with NUM_WORKERS = 2 and sending one SIGUSR2: LIVE WORKERS AFTER ROLLING RESTART = 4. On an 8-core box that's 16 workers after one deploy, 32 after two. With the retiring set it stays at 2, and a SIGKILL-ed worker is still replaced exactly once.

Note also the clearTimeout(force). Without it every retirement leaves a live 10-second timer holding a reference to a dead worker, which keeps the primary's event loop busy and will call .kill() on an already-exited handle.

⚠️

server.close() stops accepting new connections but doesn't close existing long-lived ones (WebSockets, keep-alive HTTP). You need to explicitly track and destroy open sockets for a truly clean shutdown.

IPC: Primary–Worker Communication

Workers can't share memory, but they can send messages over the IPC channel:

// Primary: aggregate metrics from all workers
if (cluster.isPrimary) {
  const metrics: Record<number, { requests: number }> = {};
 
  cluster.on("message", (worker, message) => {
    if (message.type === "metrics") {
      metrics[worker.id] = message.data;
    }
  });
 
  setInterval(() => {
    const total = Object.values(metrics).reduce((sum, m) => sum + m.requests, 0);
    console.log(`Total requests/min across all workers: ${total}`);
  }, 60_000);
}
 
// Worker: report metrics to primary
let requestCount = 0;
 
app.use((req, res, next) => {
  requestCount++;
  next();
});
 
setInterval(() => {
  process.send?.({ type: "metrics", data: { requests: requestCount } });
  requestCount = 0;
}, 60_000);

IPC messages are JSON-serialized by default, so don't send large payloads and don't assume rich types survive the trip. Forking a child and sending it { date: new Date(), map: new Map([['a',1]]), u: undefined, buf: Buffer.from('hi') }, the child receives:

default   → {"date":"2026-08-03T09:55:54.701Z","map":{},"buf":{"type":"Buffer","data":[104,105]}}
             date is a string, the Map is an empty object, `u` is gone entirely
advanced  → date is a Date, map is a Map, buf is a Buffer, `u` is present

The second line is fork(path, [], { serialization: 'advanced' }), which switches to the V8 structured-clone serializer. Reach for it when you're passing anything other than plain JSON — but it's still a copy across a pipe, so it's for control messages and small metric objects, not for streaming 10 MB between processes.

Variations

child_process.fork(): Run a Separate Script

When you need to offload a specific Node.js task (not share a port), fork() is cleaner than the cluster module:

import { fork } from "node:child_process";
 
// import.meta.url, not __dirname — in an ES module __dirname throws
// "ReferenceError: __dirname is not defined".
const workerPath = new URL("./report-generator.js", import.meta.url);
 
const worker = fork(workerPath);
 
worker.send({ jobId: "rpt_123", userId: "usr_456" });
 
worker.on("message", (result) => {
  console.log("Report ready:", result);
  worker.kill(); // Clean up after the job
});
 
worker.on("exit", (code) => {
  if (code !== 0) {
    console.error(`Report generator crashed with code ${code}`);
  }
});
// report-generator.ts — runs in its own process
process.on("message", async ({ jobId, userId }: { jobId: string; userId: string }) => {
  const report = await buildReport(userId);
  process.send?.({ jobId, url: report.url });
});

This is the pattern for things like PDF generation, data exports, or any task where you want process isolation (if it crashes, your main server stays up) and don't need shared memory.

child_process.spawn(): Shell Commands and External Binaries

For running shell commands or non-Node programs, spawn() streams stdout/stderr instead of buffering:

import { spawn } from "child_process";
 
function runFFmpeg(inputPath: string, outputPath: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const proc = spawn("ffmpeg", ["-i", inputPath, "-codec", "copy", outputPath]);
 
    proc.stderr.on("data", (data) => {
      // ffmpeg writes progress to stderr — not an error
      process.stdout.write(data);
    });
 
    proc.on("close", (code) => {
      code === 0 ? resolve() : reject(new Error(`ffmpeg exited with code ${code}`));
    });
  });
}

Use spawn() for long-running processes that produce streaming output. Use execSync() or exec() only for quick one-shot commands where you need the full output at once — and sanitize any user input before passing it to the shell.

🚨

Never pass user input directly to exec() or any spawn call with shell: true. That's a shell injection vulnerability. Use spawn() with an explicit argument array instead, and validate each argument.

PM2: Let the Process Manager Handle It

Honestly, for most production apps, you shouldn't write the clustering boilerplate above yourself. PM2 does all of it — plus process monitoring, log aggregation, auto-restart on crash, and zero-downtime deploys:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: "api",
    script: "dist/server.js",
    instances: "max",       // One worker per CPU core
    exec_mode: "cluster",
    wait_ready: true,       // Wait for process.send("ready") before routing traffic
    listen_timeout: 5000,
    kill_timeout: 10000,    // Grace period for in-flight requests
    env_production: {
      NODE_ENV: "production",
    },
  }],
};
// server.ts — signal PM2 when the app is ready
const server = app.listen(3000, () => {
  process.send?.("ready"); // PM2 listens for this before routing connections
});
 
process.on("SIGINT", () => {
  server.close(() => process.exit(0));
});

Deploy with pm2 reload api --update-env and PM2 does the rolling restart automatically.

The cluster module is worth understanding because PM2 is built on top of it, and you'll hit edge cases (custom IPC messages, port-sharing logic) where you need to know what's actually happening underneath.

When NOT to Use Clustering

Stateful in-memory data. If your app stores session data, rate-limit counters, or WebSocket connections in a Map or Set, those don't exist across worker boundaries. Two requests from the same user can hit different workers with different state. Fix: push shared state to Redis or a database. Don't try to sync it over IPC.

You're already running in Kubernetes or ECS. Container orchestrators scale horizontally — they run multiple pods of your app across nodes. Running a cluster inside each pod is double-layering the same concern. One process per container is the cleaner model. Let K8s handle the pod count.

CPU-bound tasks that need shared memory. worker_threads (not clustering) is the right answer when you need true parallelism on a single computation with shared data. Clustering makes separate HTTP workers; worker threads make parallel compute threads with optional shared memory via SharedArrayBuffer.

Small apps or local development. The overhead of forking N processes, managing their lifecycle, and piping their logs through PM2 is meaningless if you're running on a single-core VPS or a laptop with npm run dev. Add it when you actually need it — when profiling shows your event loop is the bottleneck.

Decision Tree

Rendering diagram...

The Takeaway

The cluster module is one line of code to understand: cluster.fork() spawns a copy of your process that shares the same server port. Everything else — graceful shutdown, IPC, auto-restart — is plumbing you add on top.

In practice, use PM2 cluster mode for production HTTP servers and save the raw cluster module for situations where you need custom IPC logic or tight control over the worker lifecycle. Use child_process.fork() when you need to isolate a specific Node.js job. Use spawn() for external processes.

The one thing that trips people up: clustering is not a substitute for non-blocking I/O. Eight workers all waiting on the same slow database query are still eight waiting workers. Fix your query first, then scale out.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
Reading a 2 GB file all at once doesn't run out of memory — it hits V8's 512 MB string limit first. Streams process it in flat memory instead. Here's the pipeline pattern, custom Transform streams, and backpressure, with the numbers measured on Node 22.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read