Cluster & Scaling
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of
Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked
against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. One
Node.js process runs your JavaScript on one thread, so a busy server uses one core of the machine however many it has. The scaling question is
how to put the other cores, and the other machines, to work without breaking the things a single process gives you for free: shared memory,
one place to keep a session, and one process to restart. The node:cluster module is Stability 2 (Stable) and forks a pool of identical worker
processes that share a server port; os.availableParallelism() is Stable, as are the child_process IPC and the net handle passing it
builds on. The deprecated names cluster.isMaster and cluster.setupMaster() still exist but belong to older code. Around
that API this page covers what the documentation leaves to you: keeping workers alive, replacing them without dropping requests, moving state out
of the process, keeping a client on one worker when you must, running under PM2, deciding between cluster and container replicas, and proving
the result with a load test. Threads inside one process are the subject of Worker Threads, spawning
other programs of Child Processes, and the choice between all of them of
Concurrency Models. The running scenario is the Bookshelf application described in
Introduction & Architecture.
The stability and version map
| API | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
|
|
Stable |
Added in v0.11.2. Round-robin is the default everywhere except Windows. The policy is frozen once the first worker is forked. |
|
Stable |
The |
|
Stable |
|
|
Stable |
Added in v19.4.0 and v18.14.0. The documentation says |
|
Stable |
Sending sockets over IPC is not supported on Windows. |
|
Stable |
Since v19.0.0 |
Why scale beyond one process
Three limits push a Node.js service past a single process. The first is CPU: JavaScript runs on one thread, so a service that spends 1 ms of CPU per request tops out near a thousand requests per second per process, and the other cores sit idle (see Don’t Block the Event Loop). The second is availability: one process is one failure domain, and an uncaught exception, an out-of-memory kill or a deploy takes the whole service down with it. The third is throughput of the machine itself, which has a ceiling, so at some point you need more machines.
Three strategies cover these, and they compose.
| Strategy | What you do | Typical Node.js tool |
|---|---|---|
Vertical (scale up) |
Give one process more CPU or memory. |
A bigger machine; |
Horizontal on one machine (scale out, cloning) |
Run several identical copies of the program and spread connections across them. |
|
Horizontal across machines |
The same cloning, behind a load balancer or an orchestrator. |
Reverse proxy, Kubernetes |
Decomposing |
Split the application by function so each part scales on its own (an image-resizing service, a job runner). |
Separate services, message queues (see Background Jobs, Scheduling & Queues). |
Splitting by data |
Route each user, tenant or key to one specific instance, which owns that data. |
Sticky routing or sharding; see Sticky sessions, done correctly. |
The Node.js Learn article on concurrency models (https://nodejs.org/learn/concurrency/comparing-nodejs-concurrency-models) compares these options with worker threads and child processes.
Cloning is the cheapest of these because it changes no application code, which is exactly why it is easy to do wrong: the clones do not share memory, so any code that assumed one process (an in-memory cache, a session map, a rate-limit counter, a WebSocket room) is now wrong in a way that only shows up under load.
How node:cluster works
A cluster is one primary process and N worker processes. The primary does not run your request handlers; it forks the workers, listens for their messages and deaths, and, depending on the scheduling policy, hands connections to them. Each worker is a complete Node.js process with its own V8 heap, its own event loop and its own libuv thread pool (see The Event Loop), so it also pays its own startup time and baseline memory.
accepts connections, supervises"] primary -- "IPC channel and handle passing" --> w1["Worker 1
own heap, own event loop"] primary -- "IPC channel and handle passing" --> w2["Worker 2
own heap, own event loop"] primary -- "IPC channel and handle passing" --> w3["Worker 3
own heap, own event loop"] w1 --> store[("Shared state
database, Redis")] w2 --> store w3 --> store
Under the hood the workers are started with child_process.fork(), so every worker has an IPC channel to the primary, and the two sides can
exchange messages and even network handles over it (see Child Processes). A process is a worker when
the environment variable NODE_UNIQUE_ID is set, which is how cluster.isPrimary and cluster.isWorker are computed. The same script runs in
both roles, which is why most examples start with an if (cluster.isPrimary) branch.
The trick that lets many processes share one port is in server.listen(). When a worker calls it, the call is forwarded to the primary, which
opens the socket once (or reuses it) and then either accepts connections itself and passes each one to a worker, or shares the listening socket
with the workers. Because the primary does the work on a worker’s behalf, three things differ from a normal process, all documented by Node.js:
| Call | Behaviour inside a cluster |
|---|---|
|
File descriptor 7 of the primary is listened on and the handle is passed to the worker, not the worker’s own descriptor 7. |
|
The worker uses the handle you supply and does not talk to the primary. |
|
Every worker receives the same "random" port: random the first time, identical afterwards. Compute a distinct port from |
On Windows it is not yet possible to set up a named pipe server in a worker, and Node.js does not manage the worker count for you: if no workers are alive, existing connections are dropped and new ones refused. Everything that follows is the code that closes those gaps.
A first cluster
The smallest useful cluster forks one worker per core and lets each worker run an ordinary HTTP server. The response tells you which worker
answered, and Connection: close makes every curl request open a new connection so the balancing is visible.
import cluster from 'node:cluster';
import { createServer } from 'node:http';
import { availableParallelism } from 'node:os';
import process from 'node:process';
const port = Number(process.env.PORT ?? 8000);
const size = Number(process.env.WORKERS ?? availableParallelism());
if (cluster.isPrimary) {
console.log(`primary ${process.pid} forking ${size} workers`);
for (let i = 0; i < size; i++) cluster.fork();
cluster.on('listening', (worker, address) => {
console.log(`worker ${worker.id} (pid ${worker.process.pid}) listening on ${address.address ?? '*'}:${address.port}`);
});
cluster.on('exit', (worker, code, signal) => {
console.log(`worker ${worker.id} exited (${signal ?? code})`);
});
} else {
createServer((req, res) => {
res.setHeader('Connection', 'close'); // new connection per request: shows the balancing
res.end(`worker ${cluster.worker.id} pid ${process.pid}\n`);
}).listen(port);
}
Source: Node.js API: cluster.fork(), Node.js API: cluster, Event: 'listening', Node.js API: cluster, Event: 'exit'
Started with three workers and queried six times:
PORT=38401 WORKERS=3 node basic.js &
for i in 1 2 3 4 5 6; do curl -s localhost:38401; done | sort | uniq -c
primary 58085 forking 3 workers
worker 3 (pid 58089) listening on *:38401
worker 2 (pid 58088) listening on *:38401
worker 1 (pid 58087) listening on *:38401
2 worker 1 pid 58087
2 worker 2 pid 58088
2 worker 3 pid 58089
Source: Node.js API: Cluster
The 'listening' event on cluster fires in the primary when a worker’s server is really accepting, with the address in its second argument;
'online' fires earlier, when the worker process has merely started, and 'fork' earlier still. Use 'listening' as your readiness signal, as the
rolling restart below does. Output order differs between runs and between Node.js lines, which is observed behaviour and no cause for concern.
Worker settings and cluster.setupPrimary()
By default a worker re-runs the same entry file (process.argv[1]) with the same arguments. cluster.setupPrimary() changes that for the
workers forked afterwards; it never affects running ones and cannot change the env that is passed to each cluster.fork() call.
| Setting | Meaning |
|---|---|
|
The file a worker runs. Default: |
|
Arguments for the worker script and flags for the |
|
Working directory and user and group identity of the worker. |
|
Whether worker output goes to the primary’s stdio. |
|
|
|
A number or a function returning one; by default each worker gets its own inspector port counted up from the primary’s |
|
Hide the console window on Windows. |
Source: Node.js API: cluster.settings
Scheduling policy: who gets the connection
The cluster module supports two ways of distributing incoming connections, selected through cluster.schedulingPolicy:
-
cluster.SCHED_RR: round-robin. The primary listens, accepts each connection and hands it to the next worker in turn, with some built-in logic to avoid overloading a worker. This is the default on every platform except Windows. -
cluster.SCHED_NONE: the primary creates the listening socket and shares it, and the operating system decides which worker’saccept()wins. In theory that is the fastest path; in practice the documentation reports distribution that can be very unbalanced, with more than 70 % of connections landing in two of eight processes.
The setting is global and effectively frozen once the first worker is forked or setupPrimary() is called, so set it first. It can also come
from the environment variable NODE_CLUSTER_SCHED_POLICY with the values rr and none, which is handy for experiments because it needs no
code change. The next program counts requests per worker by sending each one to the primary over IPC, and prints the tally when it receives
SIGTERM.
import cluster from 'node:cluster';
import { createServer } from 'node:http';
import process from 'node:process';
const port = Number(process.env.PORT ?? 8000);
const size = Number(process.env.WORKERS ?? 4);
if (cluster.isPrimary) {
const hits = new Map(); // worker id -> request count
for (let i = 0; i < size; i++) {
const worker = cluster.fork();
worker.on('message', (msg) => { // worker -> primary
if (msg?.cmd === 'hit') hits.set(worker.id, (hits.get(worker.id) ?? 0) + 1);
});
}
process.on('SIGTERM', () => {
console.log('policy:', cluster.schedulingPolicy === cluster.SCHED_RR ? 'SCHED_RR' : 'SCHED_NONE');
console.log('requests per worker:', Object.fromEntries(hits));
for (const worker of Object.values(cluster.workers)) {
worker.send({ cmd: 'bye' }); // primary -> each worker (a broadcast)
}
cluster.disconnect(() => process.exit(0));
});
} else {
createServer((req, res) => {
process.send({ cmd: 'hit' }); // worker -> primary
res.end('ok\n');
}).listen(port);
process.on('message', (msg) => {
if (msg?.cmd === 'bye') console.error(`worker ${cluster.worker.id} told to stop`);
});
}
Source: Node.js API: worker, Event: 'message', Node.js API: worker.send(), Node.js API: process.send(), Node.js API: cluster.schedulingPolicy
It was driven with 8 persistent connections for 3 seconds, once per policy, on a 10-core macOS machine with the load generator on the same host:
NODE_CLUSTER_SCHED_POLICY=none PORT=38402 node ipc.js &
npx autocannon -c 8 -d 3 http://127.0.0.1:38402
kill -TERM %1
| Node.js | SCHED_RR | SCHED_NONE |
|---|---|---|
26.10.0 |
25.0 %, 25.0 %, 25.0 %, 25.0 % |
25.0 % each, in two runs out of two |
24.21.0 |
25.0 %, 25.0 %, 25.0 %, 25.0 % |
14.5 %, 14.5 %, 43.4 %, 27.6 % |
22.23.3 |
25.0 %, 25.0 %, 25.0 %, 25.0 % |
62.1 %, 12.7 %, 12.6 %, 12.6 % |
Those numbers are observations on one machine, not a guarantee: round-robin was even every time, while SCHED_NONE was skewed on 24 and 22 and
even on 26 in this setup. That matches the documented warning, and it is the reason to keep the default unless a measurement on your own hardware
says otherwise. Round-robin is balanced by connection, not by work: a worker holding a few long keep-alive connections that each issue
expensive requests is as loaded as it can get while the count looks even. If your load is made of a few long-lived connections (WebSockets,
HTTP/2 or gRPC channels), put a load balancer that balances per request in front, or use more connections than workers.
How many workers: availableParallelism()
The standard answer is one worker per core, and the right way to ask for the core count is os.availableParallelism(). The documentation
describes it as an estimate of the default amount of parallelism a program should use, always greater than zero, and states that
os.cpus().length should not be used for this. The difference shows up in containers, where the CPU limit is a quota the host enforces and not a
smaller machine. These values were measured on a 10-core host with Docker, using the official node images:
docker run --rm --cpus=1.5 node:26-slim \
node -p "[os.availableParallelism(), os.cpus().length].join(' ')"
| Docker flag | Node.js 26 | Node.js 24 | Node.js 22 |
|---|---|---|---|
none |
10 |
10 |
10 |
|
1 |
1 |
1 |
|
1 |
1 |
10 |
|
2 |
2 |
2 |
In every case os.cpus().length stayed at 10. The quota is rounded down, never below one on 26 and 24, while the 22.23.3 image reported the
whole host for a quota under one CPU, so do not take the number as a given on an older line and set the worker count explicitly through an
environment variable in containers (the examples read WORKERS). One worker per core is a starting point, not a law: workers that mostly wait on
a database leave a core partly idle, workers that use native add-ons or the thread pool compete for it, and every worker costs memory (an idle Node.js
worker with a small app was around 80 MB resident in the PM2 run below).
Source: Node.js API: os.availableParallelism(), libuv: miscellaneous utilities, uv_available_parallelism()
Does it actually scale?
For CPU-bound handlers, yes, and roughly in proportion to the cores that are really free. The next program spends about a millisecond of CPU in each request, using a deliberately small PBKDF2 derivation, and runs either as one plain process or as N workers.
import cluster from 'node:cluster';
import { pbkdf2Sync } from 'node:crypto';
import { createServer } from 'node:http';
import process from 'node:process';
const port = Number(process.env.PORT ?? 8000);
const workers = Number(process.env.WORKERS ?? 0); // 0 = no cluster, one process
const serve = () => createServer((req, res) => {
const key = pbkdf2Sync('bookshelf', 'salt', 5_000, 32, 'sha256'); // ~1 ms of CPU
res.end(key.toString('hex'));
}).listen(port);
if (workers > 0 && cluster.isPrimary) {
for (let i = 0; i < workers; i++) cluster.fork();
} else {
serve();
}
WORKERS=4 PORT=38408 node scale.js &
npx autocannon -c 64 -d 5 http://127.0.0.1:38408
| Workers | Requests per second | p99 latency |
|---|---|---|
1 process, no cluster |
1,798 |
63 ms |
2 |
3,519 |
36 ms |
4 |
8,187 |
15 ms |
8 |
12,298 |
13 ms |
The jump from two to four workers is larger than linear and the step to eight is not, because the load generator shares the host and the cores are not identical; treat the shape, not the digits, as the lesson. Throughput stops improving as soon as the work stops being CPU-bound: a handler that awaits a database does not get faster with more event loops, only more concurrent. The tools for finding the real bottleneck first are in Profiling & Performance.
Source: autocannon on GitHub
IPC messaging
The IPC channel between primary and worker is the same one child_process.fork() creates. worker.send(message[, sendHandle[, options]][, callback])
sends from the primary to one worker; inside a worker process.send() sends to the primary. Both sides receive with the 'message' event: on the
worker as process.on('message'), on the primary as worker.on('message') for one worker or cluster.on('message') for all of them. The
ipc.js program above uses all four directions: each worker reports a hit, and the primary broadcasts a message by looping over
cluster.workers, which is the documented way to address every worker.
Messages are serialized and parsed, so the receiver gets a copy and never a reference. The default serialization is JSON, which drops
undefined, functions and cycles, and turns a Map or a Date into something else; serialization: 'advanced' in setupPrimary() switches to the
node:v8 structured clone format. worker.send() returns false when the channel is closed or the backlog of unsent messages is too large, and
its optional callback is the flow-control hook: pass it, or an unsent message on a dead worker raises an 'error' event. A cmd or
type field is only a convention of your own, with one exception: Node.js reserves messages whose cmd property starts with NODE_ for itself (they
surface as 'internalMessage' events) and says applications should avoid them.
IPC is the right tool for control traffic: "drain now", "reload configuration", a periodic metrics snapshot. It is the wrong tool for the hot path.
In ipc.js every request sends one message to the primary, which means the single-threaded primary does work proportional to the whole cluster’s
traffic; the demonstration only works at all because the message is tiny. Aggregate in the worker and report on a timer. Do not use it as a
database either: a message from one worker to another has to travel through the primary, and nothing queues it if the target dies. For shared data
use an external store (see State outside the process), and for sharing memory between threads of one process use SharedArrayBuffer in
Worker Threads.
Auto-restart: keep the pool at full strength
Workers die: an uncaught exception, an out-of-memory kill, a native crash, or a deliberate process.exit(). The cluster module will not replace
them, and when the last one is gone the port stops answering. The primary therefore has to react to the 'exit' event, and the first decision
in that handler is whether the death was asked for. worker.exitedAfterDisconnect is true when the worker left because someone called
worker.disconnect() (or cluster.disconnect()), false for any other exit, and undefined while the worker is still running, so the handler
can respawn accidents and leave deliberate stops alone.
'exit' arguments |
exitedAfterDisconnect |
Meaning |
|---|---|---|
|
|
The worker was disconnected and finished cleanly (rolling restart, shutdown). Do not respawn. |
|
|
An uncaught exception or an explicit |
|
|
Killed from outside (the OOM killer, |
|
|
Someone terminated the worker without the graceful path. Respawn unless the whole cluster is stopping. |
Respawning immediately is a trap. A worker that crashes at startup (a bad configuration, a database that is down) would be forked, die and be forked again in a tight loop that burns a core and floods the logs. The primary below counts consecutive crashes and doubles the delay up to five seconds, resetting once a replacement is really listening. A worker should also not try to survive an uncaught exception: its state is unknown, so log it, stop accepting work and exit, and let the supervisor start a clean one (see Errors & Error Handling).
The pair of files below is the supervisor used for the rest of the page. The worker, server.js, is a plain server that also knows how to
drain; the primary, primary.js, never loads application code, because setupPrimary({ exec }) points the workers at a different file. That
separation matters for deployments: when new code is on disk, every worker forked afterwards runs it, while the long-lived primary keeps its old
code in memory and does not need to change.
// The worker: a plain HTTP server that knows how to drain.
import cluster from 'node:cluster';
import { createServer } from 'node:http';
import process from 'node:process';
const port = Number(process.env.PORT ?? 8000);
let draining = false;
const server = createServer(async (req, res) => {
if (draining) res.setHeader('Connection', 'close'); // tell keep-alive clients to reconnect
if (req.url === '/crash') {
setImmediate(() => { throw new Error('simulated bug'); });
res.end('crashing\n');
return;
}
if (req.url === '/slow') await new Promise((r) => setTimeout(r, 300)); // an in-flight request
res.end(`worker ${cluster.worker.id} pid ${process.pid}\n`);
});
server.listen(port);
process.on('message', (msg) => {
if (msg?.cmd === 'drain') draining = true;
});
Source: Node.js API: cluster.worker, Node.js API: process, Event: 'message', Node.js API: http.createServer()
// The primary: supervises workers, restarts crashed ones, and does rolling restarts.
import cluster from 'node:cluster';
import { once } from 'node:events';
import { availableParallelism } from 'node:os';
import process from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
const size = Number(process.env.WORKERS ?? availableParallelism());
const DRAIN_GRACE_MS = 500; // let keep-alive clients see `Connection: close`
const DRAIN_TIMEOUT_MS = 10_000;
let stopping = false;
let restarting = false;
let crashes = 0; // consecutive crashes, for the backoff
cluster.setupPrimary({ exec: fileURLToPath(new URL('./server.js', import.meta.url)) });
// Fork a worker and resolve when it is really accepting connections.
async function start() {
const worker = cluster.fork();
const timer = AbortSignal.timeout(10_000);
await Promise.race([
once(worker, 'listening', { signal: timer }),
once(worker, 'exit', { signal: timer }).then(() => { throw new Error('worker died while starting'); }),
]);
return worker;
}
// Ask a worker to finish its requests, then force it after a timeout.
async function stop(worker) {
if (worker.isDead()) return;
const exited = once(worker, 'exit');
worker.send({ cmd: 'drain' }); // from now on every response says Connection: close
await sleep(DRAIN_GRACE_MS);
worker.disconnect(); // close listeners, wait for open requests
const timer = setTimeout(() => worker.kill('SIGKILL'), DRAIN_TIMEOUT_MS);
await exited;
clearTimeout(timer);
}
// Replace every worker, one at a time, starting the new one BEFORE stopping the old one.
async function rollingRestart() {
if (restarting || stopping) return;
restarting = true;
try {
for (const old of Object.values(cluster.workers)) {
await start(); // capacity goes up by one ...
await stop(old); // ... and back down
console.log(`replaced worker ${old.id}`);
}
} catch (err) {
console.error('rolling restart aborted:', err.message); // old workers keep serving
} finally {
restarting = false;
}
}
cluster.on('exit', async (worker, code, signal) => {
if (stopping || worker.exitedAfterDisconnect) return; // we asked for it
crashes++;
const delay = Math.min(100 * 2 ** (crashes - 1), 5_000); // 100 ms, 200 ms, ... capped at 5 s
console.error(`worker ${worker.id} died (${signal ?? code}); respawning in ${delay} ms`);
await sleep(delay);
if (!stopping) start().then(() => { crashes = 0; }).catch((e) => console.error(e.message));
});
async function shutdown(signal) {
if (stopping) return;
stopping = true;
console.log(`${signal}: draining ${Object.keys(cluster.workers).length} workers`);
await Promise.all(Object.values(cluster.workers).map(stop));
process.exit(0);
}
process.on('SIGUSR2', rollingRestart);
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
await Promise.all(Array.from({ length: size }, start));
console.log(`primary ${process.pid} ready with ${size} workers; kill -USR2 ${process.pid} to roll them`);
Run with three workers, a request to /crash takes one worker down and the primary replaces it after 100 ms; the other two keep answering
meanwhile:
primary 84958 ready with 3 workers; kill -USR2 84958 to roll them
...
Error: simulated bug
at Immediate.<anonymous> (server.js:12:32)
Node.js v26.10.0
worker 2 died (1); respawning in 100 ms
2 worker 1 pid 84960
2 worker 3 pid 84962
2 worker 4 pid 84974
Zero-downtime rolling restarts
A rolling restart replaces the workers one at a time so that some workers are always accepting connections: after a deploy, a configuration
change or a memory-leak cleanup, the pool turns over without a gap. The figure shows the sequence the rollingRestart() function in
primary.js follows.
The design choices in primary.js, each of which has a reason:
-
Start the new worker before stopping the old one. The new worker has to boot (load modules, open pools, warm caches) and only counts once
'listening'fires. If you stop first and start second, the pool is one worker short for the whole boot time, and a one-worker cluster is simply down. -
Abort on a failed start. If the replacement dies or never listens within ten seconds, the loop stops and the remaining old workers are not touched, so a bad build cannot take the whole pool out.
-
Drain before disconnect.
worker.disconnect()closes the worker’s servers, waits for their'close'events and then closes the IPC channel; connections already open are allowed to finish. The documentation points out that it covers server connections only and does not wait for client connections, and that long-lived connections can block it forever, which is why the primary arms aSIGKILLtimer. -
Tell keep-alive clients to leave. Since v19.0.0
server.close()closes idle keep-alive connections. A worker that answers every request withConnection: closeduring a short grace period moves its clients to other workers before that happens. -
Guard against re-entry. A second
SIGUSR2while a restart is running is ignored.
The trigger is the signal SIGUSR2. SIGUSR1 is reserved by Node.js to start the inspector, and Windows does not support signals, so there the
trigger has to be something else: a message from a deploy script over a loopback-only admin socket, a watched file, or a stdin command. Run it like this:
PORT=8000 WORKERS=3 node primary.js &
kill -USR2 $! # roll every worker, one at a time
kill -TERM $! # drain and exit
primary 41022 ready with 3 workers; kill -USR2 41022 to roll them
replaced worker 1
replaced worker 2
replaced worker 3
SIGTERM: draining 3 workers
To check the claim, not just state it, this client fires a 300 ms request on a fresh connection every 50 ms for seven seconds while the restart runs, and counts what came back:
// Fires /slow requests (300 ms each) on fresh connections, 20 per second, and tallies the outcomes.
const target = process.env.TARGET ?? 'http://127.0.0.1:8000';
const tally = {};
const end = Date.now() + 7000;
const jobs = [];
while (Date.now() < end) {
jobs.push(
fetch(`${target}/slow`, { headers: { connection: 'close' } })
.then((res) => res.text().then(() => String(res.status)))
.catch((err) => `error:${err.cause?.code ?? err.message}`)
.then((outcome) => { tally[outcome] = (tally[outcome] ?? 0) + 1; }),
);
await new Promise((resolve) => setTimeout(resolve, 50));
}
await Promise.all(jobs);
console.log(tally);
| Restart method | 200 OK | Socket error | Connection refused |
|---|---|---|---|
|
138 |
0 |
0 |
|
127 |
6 |
4 |
Keep-alive clients are a different and honest caveat. The same restart under autocannon with 20 persistent connections (about one million
requests in six seconds) returned no non-2xx response but between 31 and 39 ECONNRESET errors in repeated runs, and 16 out of about 12,000 at a
fixed 2,000 requests per second. The cause is the unavoidable race of HTTP/1.1 keep-alive: a connection that is idle at the instant the old worker
closes it can receive a request from the client in the same moment, and that request gets a reset. HTTP lets a client retry an idempotent request on
a reused connection that fails this way, and a reverse proxy normally does so; no purely in-process scheme removes the race, which is why an
orchestrator or a proxy that retries is the complete answer (see Cluster, PM2 or container replicas?).
Two operational details complete the picture. First, a rolling restart replaces the workers, not the primary: a change to the primary’s own code
needs a restart of the primary, which is downtime unless something in front routes around it, so keep the primary small and stable (the reason for
exec). Second, the same drain path serves SIGTERM and SIGINT for a normal shutdown, and note that a terminal’s Ctrl+C is delivered to the
whole process group, so workers also see SIGINT unless you handle or block it; the signal rules are in
Process, Signals & Exit Codes and the shutdown sequence in
Production Deployment.
State outside the process
Each worker has its own memory, and Node.js provides no routing logic of its own, so the documentation advises designing the application so that it does not rely heavily on in-memory data for things like sessions and login. Whatever one worker stores in a variable, the next request may land on a worker that has never seen it. These are the usual victims:
| State | Symptom | Move it to |
|---|---|---|
Sessions, login state |
Users are logged out at random |
A database or Redis session store, or a signed token the client carries (see Authentication & Sessions) |
Caches |
Different workers return different data; hit rate drops by a factor of N |
A shared cache (Redis), or accept per-worker caches with short TTLs |
Rate limiting, counters |
The effective limit is N times the configured one |
An atomic shared counter |
WebSocket rooms, broadcasts |
A message reaches only the clients on the sender’s worker |
A pub/sub channel across workers (see WebSockets & Server-Sent Events) |
In-memory job queues, timers |
Each worker runs the job; or jobs are lost on restart |
A durable queue (see Background Jobs, Scheduling & Queues) |
Uploads and temp files |
The second request cannot find the file the first one wrote |
Shared storage or an object store |
The next program shows the problem and one fix side by side. Each request increments a counter held in a variable and a counter held in a SQLite
database file with node:sqlite, which every worker opens (see SQLite Built In). node:sqlite is
Stability 1.2 (Release candidate) on Node.js 26 and needs no flag on 24 and 22; on 22.23.3 it printed an ExperimentalWarning once per process in the
runs for this page, and 26 and 24 printed none. A file database only works while the workers share a filesystem, so it is a demonstration for one machine; across machines the
same idea needs a networked store.
import cluster from 'node:cluster';
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import process from 'node:process';
const port = Number(process.env.PORT ?? 8000);
if (cluster.isPrimary) {
new DatabaseSync('state.db').exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY, n INTEGER NOT NULL);
INSERT OR REPLACE INTO counter VALUES (1, 0);
`);
for (let i = 0; i < 4; i++) cluster.fork();
} else {
const db = new DatabaseSync('state.db');
db.exec('PRAGMA busy_timeout = 5000'); // wait for the other workers' write locks
const bump = db.prepare('UPDATE counter SET n = n + 1 WHERE id = 1 RETURNING n');
let local = 0; // private to this process
createServer((req, res) => {
local++;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ worker: cluster.worker.id, inMemory: local, shared: bump.get().n }));
}).listen(port);
}
After 100 requests spread over four workers, two more requests returned:
{"worker":1,"inMemory":26,"shared":101}
{"worker":3,"inMemory":26,"shared":102}
The in-memory counter reflects only the traffic one worker saw; the shared counter is the truth. Write contention is the price: busy_timeout
makes a writer wait for the lock instead of failing at once, and WAL mode lets readers proceed during a write. For anything beyond a demo, use a
server database or Redis and the patterns in Databases from Node.js. Keeping workers stateless is
good practice even for a single-core service, because it is also what makes an orchestrator able to start, stop and move instances freely.
Sticky sessions, done correctly
Sometimes a client must keep reaching the same worker: a protocol that spreads one logical session over several HTTP requests with in-memory state (long-polling transports of some real-time libraries), a legacy application that cannot yet externalize its sessions, or per-user data held in a worker on purpose. The cluster module has no built-in affinity, and the round-robin policy deliberately ignores who the client is. The right pattern takes the connection away from the cluster’s own balancing and routes it yourself:
-
The primary owns a
net.Servercreated withpauseOnConnect: true, so the accepted socket is paused and not one byte is read in the primary. -
From the socket’s remote address it picks a worker deterministically.
-
It passes the socket to that worker with
worker.send(message, socket). This hands over the underlying handle; the primary drops its own copy. -
The worker has an HTTP server that never calls
listen(). On receiving the handle it emits'connection'on that server and resumes the socket, so the HTTP parser sees the request from its first byte.
import cluster from 'node:cluster';
import { createHash } from 'node:crypto';
import { createServer as createHttpServer } from 'node:http';
import { createServer as createTcpServer } from 'node:net';
import process from 'node:process';
const port = Number(process.env.PORT ?? 8000);
const size = Number(process.env.WORKERS ?? 4);
if (cluster.isPrimary) {
const slots = []; // slot i always holds "worker i"
const spawn = (i) => { slots[i] = cluster.fork(); };
for (let i = 0; i < size; i++) spawn(i);
cluster.on('exit', (worker) => { // keep the slot count stable
const i = slots.indexOf(worker);
if (i !== -1 && !worker.exitedAfterDisconnect) spawn(i);
});
// Hash the client address (IPv4 or IPv6) into a slot number.
const slotFor = (address = '') => {
const ip = address.replace(/^::ffff:/i, ''); // IPv4-mapped IPv6 -> plain IPv4
return createHash('sha256').update(ip).digest().readUInt32BE(0) % size;
};
// pauseOnConnect: do not read a single byte here; the worker must see the whole request.
createTcpServer({ pauseOnConnect: true }, (socket) => {
const worker = slots[slotFor(socket.remoteAddress)];
if (!worker?.isConnected()) return socket.destroy(); // slot is restarting: refuse, client retries
worker.send('sticky:connection', socket, (err) => { // hand the file descriptor over
if (err) socket.destroy();
});
}).listen(port);
} else {
const app = createHttpServer((req, res) => {
res.end(`worker ${cluster.worker.id} for ${req.socket.remoteAddress}\n`);
}); // never calls listen()
process.on('message', (msg, socket) => {
if (msg !== 'sticky:connection' || !socket) return;
app.emit('connection', socket); // feed the socket to the HTTP parser
socket.resume(); // it arrived paused
});
}
Source: Node.js API: net.createServer(), pauseOnConnect, Node.js API: worker.send(), Node.js API: child_process, sending a socket object
PORT=38405 node sticky.js &
for a in 127.0.0.1 '[::1]'; do
echo "client via $a"; for i in 1 2 3 4; do curl -s -H 'Connection: close' "http://$a:38405/"; done | sort | uniq -c
done
client via 127.0.0.1
4 worker 1 for ::ffff:127.0.0.1
client via [::1]
4 worker 3 for ::1
The two clients, an IPv4 and an IPv6 address, each stayed on one worker over four separate connections. When a worker was killed with kill -9, its slot
was refilled by a new worker (id 5 in that run) and the client was served by it, with the old worker’s in-memory state gone. It behaved the same on
Node.js 26, 24 and 22.
What makes this correct, and what usually goes wrong:
| Detail | Why it matters |
|---|---|
|
Without it the primary may read the first bytes of the request before the hand-over, and the worker never sees them. The documentation’s own socket-passing example uses it for this reason. |
Pass the socket, not |
Request and response objects are not serializable, so they cannot cross an IPC channel. Only handles (sockets, servers) and plain data can. |
Normalize the address |
A dual-stack listener reports IPv4 clients as |
Stable slots |
Hash into a fixed number of slots and refill a dead slot with a new worker, so the mapping does not reshuffle when one worker restarts. Plain modulo reshuffles every client if the count changes; use consistent hashing if it must. |
Behind a proxy |
|
NAT and uneven load |
Many users behind one address share one worker, and a few heavy addresses can overload it. Affinity trades balance for locality. |
Failure |
The state in a dead worker is lost, and clients are re-routed. Sticky routing is a cache-locality tool; the source of truth must live elsewhere (see State outside the process). |
Windows |
Sending sockets over IPC is not supported there, so this pattern is POSIX-only. |
Cookie-based affinity |
Choosing by session cookie needs the request bytes, which |
The book Efficient Node.js (Samer Buna, O’Reilly 2025) presents IP hashing as a simple option, and it is, but see the outdated-material section for two defects in how such an example is easy to get wrong.
PM2
PM2 is a process manager that daemonizes your application, restarts it when it dies, collects logs and, in its cluster mode, runs several instances of
one script through node:cluster so you do not write a primary yourself. The npm metadata for the version used here (7.0.4) lists the license as
AGPL-3.0, so check that against your distribution model before you ship it inside a product. Because it is the cluster module underneath, everything
above applies: the instances do not share memory, round-robin is the default and the application has to be stateless.
An ecosystem file declares the application once. wait_ready makes PM2 wait for the instance to call process.send('ready') before treating it as
up, which is what turns pm2 reload into a rolling restart.
import { createServer } from 'node:http';
import process from 'node:process';
const server = createServer(async (req, res) => {
if (req.url === '/slow') await new Promise((r) => setTimeout(r, 300));
res.end(`pm2 instance ${process.env.NODE_APP_INSTANCE} pid ${process.pid}\n`);
});
server.listen(Number(process.env.PORT ?? 8000), () => {
process.send?.('ready'); // tell PM2 this instance accepts connections
});
// PM2 sends SIGINT first, SIGKILL after kill_timeout
process.on('SIGINT', () => {
server.close(() => process.exit(0));
server.closeIdleConnections();
});
Source: PM2: Graceful start and shutdown
module.exports = {
apps: [{
name: 'bookshelf-api',
script: './pm2app.js',
exec_mode: 'cluster', // PM2 uses node:cluster under the hood
instances: 3, // or 'max' for one per CPU
wait_ready: true, // wait for process.send('ready') before routing traffic
listen_timeout: 10000, // give up on an instance that never becomes ready
kill_timeout: 8000, // SIGKILL if SIGINT did not finish within this time
max_memory_restart: '300M', // restart an instance that leaks past this size
env: { PORT: 38406 },
}],
};
Source: PM2: Ecosystem file, PM2: Cluster mode
| Command | What it does |
|---|---|
|
Starts the declared apps; |
|
Rolling restart: starts new instances, waits for readiness, then stops old ones. Needs cluster mode. |
|
Stops and starts instances; requests can fail in between. |
|
Changes the instance count at runtime. |
|
Status, aggregated logs and a live monitor. |
|
Persist the process list and generate a boot-time service for the init system. |
|
The foreground variant meant for containers, where the container’s main process must not daemonize. |
With PM2_HOME pointed at a scratch directory, pm2 start brought up three instances (about 80 MB each), and running the same slowload.mjs
client during pm2 reload bookshelf-api produced 138 responses with status 200 and no errors; pm2 ls afterwards showed three instances with a
restart count of 1 and new process ids. PM2’s strengths are a quick start on a single server and its operational tooling; its costs are another
daemon to run and secure, behaviour that follows PM2’s own defaults (it sends SIGINT first and SIGKILL after kill_timeout), and an
overlap with what systemd or an orchestrator already provides. In a container, one process per container is normally the better fit, as the next
section explains.
Cluster, PM2 or container replicas?
The same goal, more capacity and no downtime, can be reached at different layers. Containers and orchestrators turned the cluster module from a default into a choice.
| Mechanism | Strengths | Costs | Use it when |
|---|---|---|---|
|
Shared memory, cheap to start, one process; ideal for CPU work inside a request. |
Does not isolate crashes; not for scaling an HTTP listener across cores. |
CPU-bound tasks (hashing, parsing, image work); see Worker Threads. |
|
Many processes share one port with no external component; isolated crashes. |
You own the supervisor, the restart logic and the state externalization. |
One host, a fixed machine or VM, no orchestrator. |
PM2 |
Cluster plus daemon, logs, reload and boot integration, ready-made. |
A second layer of supervision; AGPL license; awkward inside containers. |
A single server or a few VMs without an orchestrator. |
Several containers (replicas) |
The platform restarts, scales, health-checks and rolls out; resource limits per replica; the same artefact everywhere. |
Needs a load balancer or Service in front; each replica pays the baseline memory. |
Anything on Kubernetes, ECS, Cloud Run or similar. The default today. |
|
Run other programs or isolated Node.js tasks. |
Not a way to scale a listener. |
Work that must be a separate process; see Child Processes. |
On an orchestrator, run one Node.js process per container, give the container a CPU limit, and let the platform provide what this page built by hand. The
orchestrator replaces primary.js: a Deployment starts a new replica, waits for its readiness probe, removes an old replica from the Service, sends
SIGTERM and waits for the termination grace period before SIGKILL (see Deployment Strategies,
Health Probes & Lifecycle and
ReplicaSets & Deployments). The Node.js side of that contract is the SIGTERM handler from
primary.js applied to one process: stop accepting, finish in-flight requests, close pools, exit (see
Production Deployment and Docker & Containers).
Replicas also give the proxy the chance to retry the keep-alive resets described earlier, and scale across machines with
Autoscaling. Resource limits per replica are covered in
Resource Management and, for plain Docker, in
Resources, Logging & Monitoring; the NestJS equivalents are in
NestJS Deployment and Lifecycle Events & Shutdown.
Cluster inside a container still has a place: a container with a CPU limit above one core that you want to saturate, or a platform that bills per
container and has a high per-instance minimum. In that case size the worker count from availableParallelism() or an explicit variable, as measured
above, and keep the memory limit in mind, because every worker has its own heap and a single container-wide limit is shared by all of them.
Load testing
A scaling change you have not measured is a guess. Load testing answers three different questions, and conflating them produces misleading results: how much throughput one configuration handles (capacity), how latency behaves as load rises (the shape under stress), and whether a restart, deploy or failure disturbs real traffic (the resilience tests above).
Good practice follows from how the tools work:
-
Run the load generator on a different machine from the server where you can. On one host, as in the measurements on this page, the generator steals CPU from the server and flatters nothing.
-
Warm up first (JIT, connection pools, caches) and discard the first seconds.
-
Report percentiles (p50, p95, p99) and the error count, never only the average.
-
Distinguish a closed model (a fixed number of connections, each sending the next request when the last finishes, which hides queueing) from an open model (a fixed arrival rate regardless of response times, which exposes it). autocannon runs the first by default and the second with an overall rate; Artillery’s phases are arrival rates.
-
Test the real path (TLS, proxy, authentication) and a realistic mix of requests, and keep the test script in the repository so a regression can be caught in CI.
autocannon is an HTTP benchmarking tool written in Node.js that can drive HTTP/1.1 with pipelining and many connections from a single process, from the command line or as a library. The commands used for the measurements above are the whole interface you need to start:
npm install --save-dev autocannon
npx autocannon -c 64 -d 10 http://127.0.0.1:8000/ # 64 connections for 10 seconds
npx autocannon -c 20 -d 10 -R 2000 http://127.0.0.1:8000/ # fixed 2,000 requests per second
npx autocannon -c 8 -d 3 --json http://127.0.0.1:8000/ > result.json # machine-readable output
Source: autocannon on GitHub
Artillery describes a test as a scenario in YAML: phases that set the arrival rate over time, flows that describe what each virtual user does, and
checks that fail the run. The plugin ensure turns the thresholds into an exit code, so the same file works as a CI gate.
config:
target: '{{ $processEnvironment.TARGET }}'
phases:
- name: warm-up
duration: 5
arrivalRate: 20
- name: ramp
duration: 10
arrivalRate: 20
rampTo: 200
plugins:
ensure: {}
ensure:
maxErrorRate: 0.1 # percent of failed virtual users
thresholds:
- http.response_time.p99: 200
scenarios:
- flow:
- get:
url: /
TARGET=http://127.0.0.1:8000 npx artillery run load.yml
http.codes.200: ........................................ 1200
http.response_time:
median: .............................................. 0
p95: ................................................. 1
p99: ................................................. 3
vusers.failed: ......................................... 0
Checks:
ok: maxErrorRate < 0.1
ok: http.response_time.p99 < 200
With the threshold deliberately changed to 0.1 ms the same run printed fail: http.response_time.p99 < 0.1 and exited with status 1, which is
the behaviour a pipeline relies on. Run the restart test from the earlier section while the load test is running to measure what a deploy costs
you: errors during the roll are the number to watch. Use autocannon for quick checks of one endpoint and Artillery when you need scenarios,
ramps and thresholds in version control; both are devDependencies and never belong in the production image.
Pitfalls
| Pitfall | What to do instead |
|---|---|
Using |
Use |
Forking workers in a loop with no |
Respawn accidental deaths, with backoff, and skip workers whose |
Respawning in a tight loop |
Delay and cap restarts; alert on the crash count. |
Stopping the old worker before the new one listens |
Start first, wait for |
|
Arm a kill timer; long-lived connections can block disconnection indefinitely. |
Continuing after an uncaught exception |
Log, stop accepting work, exit, and let the supervisor replace the process. |
Per-request IPC to the primary |
Aggregate in the worker and report on a timer; the primary is single-threaded. |
Sessions, caches or counters in a variable |
Move them to a shared store; see State outside the process. |
Using sticky sessions as a substitute for shared state |
Treat affinity as an optimisation; a worker restart discards what it holds. |
Assuming |
Measure; the documentation reports heavy imbalance, and the runs above confirm it on two of three lines. |
Using |
It starts the inspector. Use |
Running PM2 and an orchestrator together |
Pick one supervisor per process; in containers run one process and let the platform restart it. |
Load testing from the same machine and quoting averages |
Use a separate generator, warm up, and report percentiles and error counts. |
What changed recently
| Version | Change |
|---|---|
v16.0.0 |
|
v18.14.0 and v19.4.0 |
|
v19.0.0 |
|
v22.13.0, v23.4.0 and v25.7.0 |
|
Node.js 26 line |
The |
Where the books are outdated
The scaling chapter of Efficient Node.js (Samer Buna, O’Reilly 2025) uses current names (isPrimary, availableParallelism()), which is a good sign;
the gaps are in design rather than in names. Older articles and tutorials still teach cluster.isMaster, setupMaster() and os.cpus().length, so check any
snippet against the stability table above. Points to treat with care when following that chapter:
| Topic | Current practice |
|---|---|
Rolling restart order |
Stopping a worker and then forking its replacement leaves the pool short while the new one boots, and a one-worker cluster has a gap. Start the replacement first and wait for it to listen, as |
Restart snapshot |
Iterating over a snapshot of workers taken at the start can restart a worker that already died, or skip a respawned one. Re-read |
Sticky-session example |
Sending the request and response objects to a worker over IPC cannot work, because they are not serializable. Pass the socket with |
Crash handling |
Respawning on every exit regardless of |
Containers |
The cgroup quota changes |
Load testing tools |
Packages such as |
PM2 |
|
Wexler’s Node.js Projects (Jonathan Wexler, O’Reilly 2025) mentions clustering only in concept as part of the runtime overview, so nothing in it needs correcting for this topic; the practical cluster material is the chapter above.
Where to go next
Decide between scaling mechanisms with Concurrency Models; keep a single process healthy with Don’t Block the Event Loop and Profiling & Performance; handle signals and shutdown with Process, Signals & Exit Codes and Production Deployment; and put the whole thing into an image with Docker & Containers.