Ch. 6 · Node.js

Node.js Clustering Across CPU Cores

Fork one Node.js worker per core with cluster, replace workers that crash, and plan for the state that each process keeps to itself.

~7 min readadvancedupdated Oct 19, 2026

A Node.js process runs your JavaScript on one thread. On an 8-core machine, a CPU-heavy API that saturates that thread leaves seven cores idle, and top shows a single node process pinned at 100% while the machine as a whole looks quiet. The cluster module is Node’s built-in answer: start several copies of the same server and let them share one port.

It’s an old module (it predates worker_threads by years), and in containerised deployments you may never call it directly. It’s still a common interview topic, because explaining it well means explaining processes, sockets and where state lives.

What cluster actually does

When your script runs, cluster.isPrimary is true in the first process. That process calls cluster.fork(), which starts a new Node process running the same file with cluster.isPrimary set to false. Workers are full operating-system processes created with child_process.fork(), connected to the primary by an IPC channel. Each has its own V8 heap, its own event loop and its own copy of every module-level variable.

The surprising part is the port. Every worker calls server.listen(3123), and normally a second listen on the same port fails with EADDRINUSE. In a cluster worker, listen doesn’t bind the port itself. It asks the primary over IPC, and the primary owns the socket. What happens next depends on cluster.schedulingPolicy:

  • cluster.SCHED_RR (round-robin), the default everywhere except Windows. The primary accepts each incoming connection and passes the handle to the next worker in turn.
  • cluster.SCHED_NONE. The primary creates the listening socket and gives it to the workers, and the operating system decides which worker’s accept wins. That sounds fairer, but in practice the load tends to land unevenly on a few processes, which is why round-robin became the default.

You can force the policy with the NODE_CLUSTER_SCHED_POLICY environment variable (rr or none), though there’s rarely a reason to.

Note what’s being distributed: connections, not requests. A client that reuses one keep-alive connection for 1,000 requests sends all of them to the same worker. That matters when you load-test with a tool that opens only a handful of connections.

A cluster you can run

Here’s a complete server: the primary forks four workers, and each worker counts the requests it has served.

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

const PORT = 3123;

if (cluster.isPrimary) {
  const count = Math.min(4, availableParallelism());
  console.log(`primary ${process.pid}: forking ${count} workers (cores: ${availableParallelism()})`);
  for (let i = 0; i < count; i++) cluster.fork();

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} exited (code ${code}, signal ${signal})`);
    if (!worker.exitedAfterDisconnect) {
      console.log('starting a replacement');
      cluster.fork();
    }
  });
} else {
  let hits = 0;
  http.createServer((req, res) => {
    if (req.url === '/crash') process.exit(1);
    hits++;
    res.end(`pid ${process.pid} hits ${hits}\n`);
  }).listen(PORT);
}
JavaScript

os.availableParallelism() (Node 18.14 and later) is the preferred way to count usable CPUs; older code uses os.cpus().length. I capped the demo at four. A small driver script sent eight requests, each on a fresh connection, then hit /crash, waited, and sent four more. This is the real output on an 8-core Mac:

primary 24411: forking 4 workers (cores: 8)
pid 24413 hits 1
pid 24416 hits 1
pid 24414 hits 1
pid 24415 hits 1
pid 24413 hits 2
pid 24416 hits 2
pid 24414 hits 2
pid 24415 hits 2
worker 24413 exited (code 1, signal null)
starting a replacement
crash request failed: UND_ERR_SOCKET
pid 24416 hits 3
pid 24414 hits 3
pid 24415 hits 3
pid 24426 hits 1
Text

Three things are visible. Round-robin is literal: the four workers take turns in a fixed order. The crash killed one process and the request it was handling (the client saw its socket close, reported by fetch as UND_ERR_SOCKET), but the server kept answering on the other three. And the replacement, pid 24426, started from hits 1.

State lives in each worker

That last line is the shared-state problem in one number. The server has served 15 requests, yet no process knows that. Every module-level variable exists once per worker, and a restart wipes it.

In a real application that variable is something you care about:

  • Sessions in memory: a user logs in on worker A, the next request lands on worker B, and they’re logged out. Use a shared store (Redis, the database) or stateless signed tokens.
  • Rate limiting: a limit of 100 requests per minute, counted per worker, becomes 400 with four workers. Count in a shared store.
  • In-process caches: each worker warms its own, so you hit the database four times as often while warming up, and invalidating one worker’s copy leaves the others stale.
  • WebSockets and long polling: a socket is bound to the worker that accepted it. Broadcasting to “all connected users” needs a pub/sub layer between workers, and protocols that make several HTTP requests per logical session (Socket.IO’s polling fallback) need sticky sessions so they keep landing on the same worker. Round-robin doesn’t provide that.

If you hear “we’ll just use cluster to scale” in an interview, this is the follow-up: everything that was correct with one process now has to be correct with several, which is the same work you’d do to run several machines.

Crashes, restarts and shutdown

The exit handler above restarts any worker that dies unexpectedly. worker.exitedAfterDisconnect is true when the primary deliberately called worker.disconnect() or worker.kill(), so intentional shutdowns don’t get replaced.

That handler has a flaw worth spotting. If a worker crashes during startup (a bad config value, a missing environment variable), the replacement crashes too, immediately, and the primary forks again in a tight loop, burning CPU and filling logs. Production versions count recent restarts and stop, or back off, when a worker dies, say, five times within a minute. Then the primary exits non-zero so whatever supervises it (systemd, Kubernetes, PM2) can report the failure instead of hiding it.

Graceful shutdown follows the same shape as for a single process, one level up. On SIGTERM the primary calls worker.disconnect() on each worker. A disconnected worker stops accepting new connections, finishes the requests it’s already serving, and exits once its servers have closed and nothing else (an open keep-alive socket, a timer) keeps its event loop busy. The primary waits for every exit event, with a timeout after which it calls worker.kill() on stragglers, then exits itself. A deploy then drops no requests, even though every process is replaced.

cluster, worker threads, or more containers

cluster is one of three ways to use more cores, and choosing between them is a common question.

Worker threads (node:worker_threads) run JavaScript on extra threads inside one process. They suit offloading a CPU-heavy task (image resizing, a big JSON transform) from a request handler so the event loop stays responsive. They don’t help an HTTP server accept more connections, and they share memory only through SharedArrayBuffer or message passing.

cluster multiplies the whole server inside one machine or container. It’s useful on a VM where you run a single service and want it on every core.

More containers or instances behind a load balancer is what most teams do today. Kubernetes or ECS runs N single-process replicas, each with a CPU limit of about one core, and the platform handles restarts, rolling deploys and health checks. Running cluster inside such a container duplicates the orchestrator’s job and confuses its CPU and memory accounting. A sensible rule is one Node process per container unless you have a measured reason otherwise.

Interview practice: the counter that went wrong

“We moved our Node API from one process to cluster with eight workers to use all the cores. Throughput went up nicely, but now users randomly get logged out, our /admin/stats page shows a different request total every time you refresh, and the login rate limit seems far too lenient. What happened, and how do you fix it?”

Answer

All three symptoms are per-process state. Before the change there was one process, so module-level memory acted like global memory. Now there are eight copies of it.

Sessions were stored in an in-memory store (the default MemoryStore in express-session is the usual suspect). A session created on one worker doesn’t exist on the other seven, so roughly seven in eight follow-up requests look anonymous. The stats page reads a counter that each worker increments separately, so each refresh shows whichever worker happened to get the connection. And the rate limiter counts per worker: a limit of five login attempts per minute is effectively forty.

The fix is to move every piece of shared state out of the process. Sessions go into Redis (or become signed, stateless tokens). The rate limiter uses a Redis-backed store with an atomic increment and expiry. Request totals are better as metrics: each worker exports its own counter, and the monitoring system (Prometheus, for example) sums them. I’d also look for the less obvious cases, such as in-memory caches and scheduled jobs that now run eight times, because a setInterval that sends a daily report will send it from every worker. Finally, I’d ask whether cluster is needed at all: if this runs in containers, running eight single-process replicas makes the same problems visible but gives the orchestrator control of restarts and scaling.

Further reading

More in Node.js

read ✓Node.js · hard

Node.js Caching Layers and Invalidation

Pick a cache layer in Node.js, choose TTLs per data type, keep failures out of the cache and stop a cold-cache stampede with one loader.

~7 min readread →
read ✓Node.js · hard

Node.js Password Hashing with scrypt

Hash passwords in Node.js with crypto.scrypt, a random salt and stored cost parameters, then verify logins with timingSafeEqual.

~7 min readread →
read ✓Node.js · mid

Node.js ESM and CommonJS Interop

See how Node.js picks ESM or CommonJS, why some named imports from CJS fail, when require(esm) works and how dual packages split state.

~6 min readread →
esc