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

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

node:cluster: cluster.fork(), isPrimary, isWorker, workers, worker, Worker, events

2 Stable

isPrimary and setupPrimary() arrived in v16.0.0 and replaced isMaster and setupMaster(), which are now 0 Deprecated. cluster.fork() has existed since v0.6.0.

cluster.schedulingPolicy, cluster.SCHED_RR, cluster.SCHED_NONE, NODE_CLUSTER_SCHED_POLICY

Stable

Added in v0.11.2. Round-robin is the default everywhere except Windows. The policy is frozen once the first worker is forked.

worker.send(), process.send(), the 'message' event, serialization setting

Stable

The callback parameter of worker.send() since v4.0.0; the serialization setting since v13.2.0 and v12.16.0.

worker.disconnect(), worker.exitedAfterDisconnect, cluster.disconnect()

Stable

exitedAfterDisconnect since v6.0.0; worker.disconnect() returns the worker since v7.3.0.

os.availableParallelism()

Stable

Added in v19.4.0 and v18.14.0. The documentation says os.cpus().length must not be used for this purpose.

net.createServer({ pauseOnConnect }) and passing a net.Socket through subprocess.send()

Stable

Sending sockets over IPC is not supported on Windows.

http.Server#close() and closeIdleConnections()

Stable

Since v19.0.0 server.close() also closes idle connections, which matters for the rolling restart below.

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.

Table 2. Scaling strategies
Strategy What you do Typical Node.js tool

Vertical (scale up)

Give one process more CPU or memory.

A bigger machine; --max-old-space-size (see CLI Options, Environment & Config File). Does little for a single-threaded server beyond one core.

Horizontal on one machine (scale out, cloning)

Run several identical copies of the program and spread connections across them.

node:cluster, PM2 in cluster mode, or several containers on one host.

Horizontal across machines

The same cloning, behind a load balancer or an orchestrator.

Reverse proxy, Kubernetes Deployment, a platform’s autoscaler.

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.

flowchart LR client(["Clients"]) --> port["TCP port 8000"] port --> primary["Primary process
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:

Table 3. How listen() behaves in a worker
Call Behaviour inside a cluster

server.listen({ fd: 7 })

File descriptor 7 of the primary is listened on and the handle is passed to the worker, not the worker’s own descriptor 7.

server.listen(handle)

The worker uses the handle you supply and does not talk to the primary.

server.listen(0)

Every worker receives the same "random" port: random the first time, identical afterwards. Compute a distinct port from cluster.worker.id if each worker needs its own.

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.

basic.js: one HTTP server shared by N workers
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);
}

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

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.

Table 4. The cluster.settings object
Setting Meaning

exec

The file a worker runs. Default: process.argv[1]. Pointing it at a separate file keeps the primary tiny and free of application code.

args, execArgv

Arguments for the worker script and flags for the node executable. Default: process.argv.slice(2) and process.execArgv.

cwd, uid, gid

Working directory and user and group identity of the worker.

silent, stdio

Whether worker output goes to the primary’s stdio. stdio must contain an 'ipc' entry and overrides silent.

serialization

'json' or 'advanced'; the latter uses the node:v8 structured clone format and can carry Map, Set, BigInt and typed arrays.

inspectPort

A number or a function returning one; by default each worker gets its own inspector port counted up from the primary’s process.debugPort.

windowsHide

Hide the console window on Windows.

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’s accept() 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.

ipc.js: per-worker request counts, and a broadcast, over the IPC channel
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`);
  });
}

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
Table 5. Share of requests per worker, 4 workers, 8 keep-alive connections (observed)
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(' ')"
Table 6. os.availableParallelism() inside containers on a 10-core host (observed)
Docker flag Node.js 26 Node.js 24 Node.js 22

none

10

10

10

--cpus=1.5

1

1

1

--cpus=0.5

1

1

10

--cpuset-cpus=0,1

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).

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.

scale.js: a CPU-bound server that runs with or without cluster
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
Table 7. Throughput and latency, 64 connections for 5 seconds, Node.js 26, 10-core macOS host (observed)
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.

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.

Table 8. How to read a worker exit
'exit' arguments exitedAfterDisconnect Meaning

code 0, signal null

true

The worker was disconnected and finished cleanly (rolling restart, shutdown). Do not respawn.

code 1, signal null

false

An uncaught exception or an explicit process.exit(1). Respawn, with a delay if it keeps happening.

code null, signal 'SIGKILL'

false

Killed from outside (the OOM killer, kill -9) or by your own drain timeout. Respawn.

code null, signal 'SIGTERM'

false

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.

server.js: the worker
// 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;
});
primary.js: supervisor with respawn backoff, rolling restart and graceful shutdown
// 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.

sequenceDiagram participant Ops as Deploy script participant P as Primary participant A as Worker 1 (old) participant B as Worker 4 (new) participant C as Worker 2 (old) participant Cl as Clients Cl->>P: traffic keeps arriving Ops->>P: kill -USR2 (new code is on disk) P->>B: fork() the replacement first B-->>P: 'listening' Note over A,C: capacity is now N plus one P->>A: {cmd: drain} then 500 ms grace A-->>Cl: responses say Connection: close P->>A: disconnect() A->>A: finish in-flight requests, close the server A-->>P: 'exit' with exitedAfterDisconnect true P->>C: repeat for the next old worker Note over P: if a new worker fails to listen, abort and keep the old ones

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 a SIGKILL timer.

  • Tell keep-alive clients to leave. Since v19.0.0 server.close() closes idle keep-alive connections. A worker that answers every request with Connection: close during a short grace period moves its clients to other workers before that happens.

  • Guard against re-entry. A second SIGUSR2 while 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:

slowload.mjs: tally the outcome of requests that are in flight during a restart
// 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);
Table 9. Outcome of 138 slow requests during a restart (observed)
Restart method 200 OK Socket error Connection refused

SIGUSR2 rolling restart (primary.js), Node.js 26, 24 and 22

138

0

0

SIGTERM sent to all three workers at once, primary respawns them (Node.js 26)

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:

Table 10. In-process state that breaks in a cluster
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.

state.js: a per-process counter next to a shared one
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:

  1. The primary owns a net.Server created with pauseOnConnect: true, so the accepted socket is paused and not one byte is read in the primary.

  2. From the socket’s remote address it picks a worker deterministically.

  3. It passes the socket to that worker with worker.send(message, socket). This hands over the underlying handle; the primary drops its own copy.

  4. 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.

sticky.js: IP-hash routing with pauseOnConnect and handle passing
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
  });
}
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:

Table 11. Sticky routing details
Detail Why it matters

pauseOnConnect: true

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 req and res

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 ::ffff:1.2.3.4. A hash that splits on dots, or a table keyed by one form, misroutes or produces NaN for IPv6 addresses. Strip the prefix first.

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

remoteAddress is then the proxy’s address and every client lands on one worker. A proxy must do the affinity, or send the real address in a protocol the primary can read without consuming the request, such as the PROXY protocol.

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 pauseOnConnect forbids reading. A reverse proxy in front is the practical place for cookie stickiness.

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.

pm2app.js: the instance, which reports readiness and drains on SIGINT
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();
});
ecosystem.config.cjs: three instances in cluster mode
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 },
  }],
};
Table 12. Everyday PM2 commands
Command What it does

pm2 start ecosystem.config.cjs

Starts the declared apps; -i max or instances: 'max' forks one instance per core.

pm2 reload bookshelf-api

Rolling restart: starts new instances, waits for readiness, then stops old ones. Needs cluster mode.

pm2 restart bookshelf-api

Stops and starts instances; requests can fail in between.

pm2 scale bookshelf-api 5

Changes the instance count at runtime.

pm2 ls, pm2 logs, pm2 monit

Status, aggregated logs and a live monitor.

pm2 save and pm2 startup

Persist the process list and generate a boot-time service for the init system.

pm2-runtime ecosystem.config.cjs

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.

Table 13. Choosing a scaling mechanism
Mechanism Strengths Costs Use it when

worker_threads

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.

node:cluster

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.

child_process

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

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.

load.yml: a 15-second Artillery test with pass or fail thresholds
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
Excerpt of the summary
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

Table 14. Cluster and scaling pitfalls
Pitfall What to do instead

Using os.cpus().length for the worker count

Use os.availableParallelism(), and an explicit variable in containers, where values differ by Node.js line.

Forking workers in a loop with no 'exit' handler

Respawn accidental deaths, with backoff, and skip workers whose exitedAfterDisconnect is true.

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 'listening', then drain the old one.

worker.disconnect() that never completes

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 SCHED_NONE is faster

Measure; the documentation reports heavy imbalance, and the runs above confirm it on two of three lines.

Using SIGUSR1 as a reload signal

It starts the inspector. Use SIGUSR2 (POSIX only) or an IPC or admin command, which also works on Windows.

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

Table 15. Changes relevant to scaling Node.js, by version
Version Change

v16.0.0

cluster.isPrimary and cluster.setupPrimary() introduced; isMaster and setupMaster() became documentation-deprecated.

v18.14.0 and v19.4.0

os.availableParallelism() added.

v19.0.0

http.Server#close() closes idle connections, which changed how a graceful drain of keep-alive clients behaves.

v22.13.0, v23.4.0 and v25.7.0

node:sqlite, used above for shared state, lost its flag in 22.13 and 23.4 and became a Release candidate in v25.7.0.

Node.js 26 line

The node:cluster documentation lists no change after v16.0.0, so the scheduling policy, exitedAfterDisconnect and the settings are the same as on 24 and 22. The observable differences in this page’s runs were the SCHED_NONE balance (skewed on 24 and 22, even on 26) and availableParallelism() under a sub-core container quota (the whole host on 22 only).

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:

Table 16. Where to verify scaling advice in the books
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 primary.js does; add a timeout and a failure path.

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 cluster.workers or track slots.

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 pauseOnConnect as shown here. An address hash that splits on dots also fails on IPv6 and IPv4-mapped addresses.

Crash handling

Respawning on every exit regardless of exitedAfterDisconnect, or with no backoff, turns a startup failure into a busy loop.

Containers

The cgroup quota changes availableParallelism() and often makes one process per container the better design; see the replicas section.

Load testing tools

Packages such as loadtest still work, but autocannon and Artillery are maintained and give percentiles, fixed arrival rates and threshold checks that the book’s examples do not use.

PM2

pm2 reload is a rolling restart only in cluster mode and with readiness signalling (wait_ready); use an ecosystem file rather than command-line flags, and check the license.

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.