jsguides

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 older cluster.isMaster. The old name still works but is deprecated, so use isPrimary in new code.
  • Whether a process is the primary is decided by process.env.NODE_UNIQUE_ID being undefined. That is also how a forked worker identifies itself.
  • The two flags are exact opposites. You rarely need isWorker as a separate branch; the else of an isPrimary check 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

MemberWhat it gives you
worker.idA unique number per worker. Key into cluster.workers while the worker is alive.
worker.processThe ChildProcess returned by child_process.fork(). Inside a worker, this is the global process.
worker.exitedAfterDisconnecttrue if the worker exited because you called .disconnect(). undefined while it is still running. Use this to decide whether to respawn.

Worker methods

MethodReturnsWhat it does
worker.isConnected()booleantrue while the IPC channel is open.
worker.isDead()booleantrue after the process has actually terminated.
worker.disconnect()thisCloses all servers in the worker, waits for them, then disconnects the IPC channel.
worker.kill([signal])voidDisconnects (in the primary) and sends the signal. Defaults to 'SIGTERM'. Aliased as worker.destroy().
worker.send(message[, sendHandle[, options]][, callback])booleanSends 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:

PropertyDefaultNotes
execprocess.argv[1]Worker script path.
argsprocess.argv.slice(2)Args passed to the worker.
execArgvprocess.execArgvNode CLI flags for workers.
cwdinheritsWorking 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+).
silentfalseIf true, pipe worker stdout and stderr to the parent.
stdioinherits from spawn()Override stdio (v6.4.0+).
windowsHidefalseHide the worker console window on Windows (v9.4.0+).
inspectPortundefinedInspector 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:

EventCallbackWhen 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 the NODE_CLUSTER_SCHED_POLICY environment 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 from cluster.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:cluster when 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_threads when 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.isMaster into a fresh codebase. It still works but is deprecated; use cluster.isPrimary.
  • Assuming workers share state. They do not. Anything in process.env of 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 a kill() 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.schedulingPolicy after 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, process is the ChildProcess for that worker, so process.kill() sends a signal directly. Use cluster.worker.disconnect() for the orderly path.

See Also

  • child_processcluster.fork() is built on child_process.fork().
  • worker_threads — the alternative when you do not need process isolation.
  • processprocess.send, process.on('message'), and process.env.NODE_UNIQUE_ID are referenced from cluster code.