cluster
cluster.fork([env]): Worker What the node:cluster module is
The node:cluster module lets a single Node.js process, called the primary, fork worker processes that all share the same server ports. Each worker is its own OS process with its own event loop and its own memory, so a single hung request does not freeze the whole server. Workers talk to the primary over an IPC channel built on child_process.fork().
Use it when you need to scale a TCP or HTTP server across every CPU core, and you are willing to design for statelessness. If you do not need process isolation, use worker_threads instead; the official docs call this out directly.
Import the module with the node: prefix in either ESM or CommonJS:
import cluster from 'node:cluster';
// or
const cluster = require('node:cluster');
Named exports work too. They keep call sites shorter and are common in worker files that only need a single boolean or method to do their job — this is the form you’ll see in most real codebases:
import { isPrimary, fork, setupPrimary } from 'node:cluster';
Cluster setup: cluster.isPrimary and cluster.isWorker
Every process running under the cluster module is either the primary or a worker. The two booleans cluster.isPrimary and cluster.isWorker are the canonical way to branch:
import cluster from 'node:cluster';
import http from 'node:http';
if (cluster.isPrimary) {
// fork workers here
} else {
http.createServer((req, res) => res.end('ok\n')).listen(8000);
}
A few things worth knowing:
isPrimary(added in v16.0.0) replaced the oldercluster.isMaster. The old name still works but is deprecated, so useisPrimaryin new code.- Whether a process is the primary is decided by
process.env.NODE_UNIQUE_IDbeing undefined. That is also how a forked worker identifies itself. - The two flags are exact opposites. You rarely need
isWorkeras a separate branch; theelseof anisPrimarycheck is enough.
Forking workers with cluster.fork()
cluster.fork([env]) is the only way to spawn a worker. It returns a Worker instance:
import cluster from 'node:cluster';
if (cluster.isPrimary) {
const worker = cluster.fork();
console.log(worker.id); // 0
}
The optional env argument is a plain object whose key/value pairs are merged on top of process.env inside the new worker. It is the right place to push per-worker config such as a region tag, log level, or feature flag:
// In the primary
const worker = cluster.fork({ WORKER_REGION: 'eu-west-1', LOG_LEVEL: 'debug' });
// Inside the worker
console.log(process.env.WORKER_REGION); // 'eu-west-1'
fork() can only be called from the primary; in a worker it throws. There is no built-in pool; you decide how many to fork. os.availableParallelism() is the modern way to size it, since it accounts for cgroup and container limits that the older os.cpus().length ignores:
import { availableParallelism } from 'node:os';
for (let i = 0; i < availableParallelism(); i++) {
cluster.fork();
}
The Worker class: events and methods
Worker extends EventEmitter, so you can attach listeners to it in the primary. The events fired on a Worker match the module-level events of the same name.
Worker properties
| Member | What it gives you |
|---|---|
worker.id | A unique number per worker. Key into cluster.workers while the worker is alive. |
worker.process | The ChildProcess returned by child_process.fork(). Inside a worker, this is the global process. |
worker.exitedAfterDisconnect | true if the worker exited because you called .disconnect(). undefined while it is still running. Use this to decide whether to respawn. |
Worker methods
| Method | Returns | What it does |
|---|---|---|
worker.isConnected() | boolean | true while the IPC channel is open. |
worker.isDead() | boolean | true after the process has actually terminated. |
worker.disconnect() | this | Closes all servers in the worker, waits for them, then disconnects the IPC channel. |
worker.kill([signal]) | void | Disconnects (in the primary) and sends the signal. Defaults to 'SIGTERM'. Aliased as worker.destroy(). |
worker.send(message[, sendHandle[, options]][, callback]) | boolean | Sends a message over IPC. Returns true if the message was queued, false otherwise. The options.keepOpen flag (default false) keeps a net.Socket open in the sender. |
The 'listening', 'online', 'fork', and 'exit' events all fire only in the primary. Inside a worker you listen on process for the same messages, because process is the worker’s own ChildProcess.
In the primary, cluster.workers is a live hash keyed by worker.id. A common pattern is to attach the same listener to every existing worker at once:
for (const id in cluster.workers) {
cluster.workers[id].on('exit', (code, signal) => {
console.log(`worker ${id} exited with code=${code} signal=${signal}`);
// worker 0 exited with code=0 signal=null
});
}
cluster.setupPrimary and cluster.settings
cluster.setupPrimary(settings) configures how future fork() calls behave. It only affects workers forked after the call; existing workers keep their old settings. The old alias cluster.setupMaster() is deprecated since v16.0.0; use the new name.
import cluster from 'node:cluster';
cluster.setupPrimary({
exec: 'app.worker.js',
args: ['--use-strict'],
execArgv: ['--inspect=0.0.0.0:9229'],
serialization: 'advanced',
silent: false,
});
console.log(cluster.settings);
// { exec: 'app.worker.js', args: ['--use-strict'], ... }
The most useful settings:
| Property | Default | Notes |
|---|---|---|
exec | process.argv[1] | Worker script path. |
args | process.argv.slice(2) | Args passed to the worker. |
execArgv | process.execArgv | Node CLI flags for workers. |
cwd | inherits | Working directory for workers (v9.5.0+). |
serialization | 'json' | 'json' or 'advanced'. The advanced mode uses child_process advanced serialization (v13.2.0 / v12.16.0+). |
silent | false | If true, pipe worker stdout and stderr to the parent. |
stdio | inherits from spawn() | Override stdio (v6.4.0+). |
windowsHide | false | Hide the worker console window on Windows (v9.4.0+). |
inspectPort | undefined | Inspector port for workers (v8.2.0+). |
cluster.settings is a read-only view of the same object, populated after the first setupPrimary() or fork(). Mutate it through setupPrimary(), not by writing to the property.
Cluster events
The cluster object itself emits events that are useful when you want to react to every worker at once:
| Event | Callback | When it fires |
|---|---|---|
'fork' | (worker) | A new worker is being spawned. |
'online' | (worker) | The worker has started running. |
'listening' | (worker, address) | A worker called server.listen(). |
'message' | (worker, message, handle) | A worker sent an IPC message. |
'disconnect' | (worker) | The worker’s IPC channel closed. Can lag 'exit'. |
'exit' | (worker, code, signal) | A worker died. code is the exit code, signal is the signal name. |
'setup' | (settings) | setupPrimary() was called. The argument is the snapshot at call time; cluster.settings is more reliable. |
'fork' fires before 'online': the first is the act of forking, the second is confirmation the worker is actually running.
The 'listening' event hands you an address object with address, port, and family keys, so the primary can log or balance based on the actual bound port:
cluster.on('listening', (worker, address) => {
console.log(`worker ${worker.id} on ${address.address}:${address.port}`);
// worker 0 on 0.0.0.0:8000
});
How connection distribution works
Cluster workers share ports because the primary does the actual listening. When a worker calls server.listen(port), the listen handle is serialized over IPC to the primary, the primary listens, and incoming connections are handed back to workers. The default distribution is round-robin (cluster.SCHED_RR); the alternative is cluster.SCHED_NONE, where the OS decides.
Round-robin is the default on every OS except Windows. It is the right default in practice. The Node docs note that the OS policy can leave over 70% of connections piled on two of eight processes.
A few specific behaviors to keep in mind:
- The policy is frozen after the first worker spawn or the first
setupPrimary()call. You can also set it ahead of time with theNODE_CLUSTER_SCHED_POLICYenvironment variable ('rr'or'none'). server.listen({ fd: 7 })listens on the parent’s file descriptor 7, not the worker’s.server.listen(handle)uses the handle as-is and bypasses the primary entirely; no connection distribution happens for that socket.server.listen(0)picks a random port on the first call, then reuses the same port for every worker after that. If you need a unique random port per worker, build it fromcluster.worker.id:
// Inside a worker — derive a port from the worker id
import { createServer } from 'node:http';
import { worker } from 'node:cluster';
const port = 3000 + worker.id;
createServer((req, res) => res.end(`worker ${worker.id}\n`)).listen(port);
- Named pipe servers cannot be created in a worker on Windows.
Graceful shutdown with cluster.disconnect()
cluster.disconnect([callback]) calls .disconnect() on every worker, waits for the IPC channels and internal handles to close, then calls the callback. It is primary-only.
import cluster from 'node:cluster';
if (cluster.isPrimary) {
const worker = cluster.fork();
worker.on('listening', () => {
worker.send('shutdown');
worker.disconnect();
setTimeout(() => worker.kill(), 2000); // hard fallback
});
}
A disconnect() call on a worker waits for its open server connections to close. If a client holds a connection open forever, the worker will not exit. The usual fix is a setTimeout(() => worker.kill(), n) fallback, or sending a shutdown message over IPC and closing sockets in your own application code.
The worker.exitedAfterDisconnect flag tells you whether the exit was voluntary. In the primary:
cluster.on('exit', (worker, code, signal) => {
if (worker.exitedAfterDisconnect) {
// voluntary shutdown, no need to respawn
return;
}
cluster.fork(); // crash recovery
});
cluster vs worker_threads
Both modules run code off the main thread, but they solve different problems:
- Use
node:clusterwhen you need to scale a TCP or HTTP server across CPU cores. Each worker is a full OS process, so a crash in one worker does not affect the others. - Use
node:worker_threadswhen you need to run CPU-heavy JavaScript (parsing, crypto, image work) without blocking the event loop. Threads share memory, which makes shared state cheap.
If your goal is “use all the cores for an HTTP server,” pick cluster. If your goal is “offload a CPU-bound calculation,” pick worker_threads. You can use both in the same application.
Common mistakes
A few things that bite people regularly:
- Mixing
cluster.isMasterinto a fresh codebase. It still works but is deprecated; usecluster.isPrimary. - Assuming workers share state. They do not. Anything in
process.envof the primary is inherited, but runtime mutations do not propagate. Use a database or the IPC channel for cross-worker state. - Forgetting that
worker.disconnect()waits for long-lived connections to close. Without akill()fallback, your graceful shutdown hangs forever. - Relying on
server.listen(0)to give each worker a unique port. It does not, after the first call. - Forgetting that cluster does not auto-respawn workers. The
'exit'listener above is the minimum you need. - Setting
cluster.schedulingPolicyafter the first fork and expecting it to apply. It is frozen at that point. - Trying to build routing logic on top of cluster. The module is intentionally minimal. Sticky sessions, rate limiting, and load balancing all belong in your application or a load balancer in front, not in the cluster module.
- Calling
process.kill()inside a worker and expecting it to disconnect gracefully. Inside a worker,processis theChildProcessfor that worker, soprocess.kill()sends a signal directly. Usecluster.worker.disconnect()for the orderly path.
See Also
child_process—cluster.fork()is built onchild_process.fork().worker_threads— the alternative when you do not need process isolation.process—process.send,process.on('message'), andprocess.env.NODE_UNIQUE_IDare referenced from cluster code.