Cluster as a Legacy Scaling Primitive
Cluster starts with one pretty strange fact when you first see it - multiple Node processes can all run server.listen(3000) inside the same application. Yes, all of them, same port, and nobody complains.
import cluster from "node:cluster";
import http from "node:http";
import os from "node:os";
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) {
cluster.fork();
}
} else {
http
.createServer((req, res) => res.end(`${process.pid}\n`))
.listen(3000);
}Every worker eventually reaches that exact same listen(3000) call. Normally you would expect the second process to just fail because port 3000 is already taken, no? But with cluster, the listen request goes through Node's primary process. The worker asks the primary for a server handle over the cluster IPC channel, and the primary keeps track of the shared listening state for that address.
Then incoming connections can be handed to the workers. And yeah, that's pretty much the main reason node:cluster exists.
You may hear cluster called "legacy", but that doesn't mean the API disappeared or you can't use it anymore. It's still there and stable in Node v24. Legacy here is more about how people used to deploy Node services i.e one Node program starts on one machine, that primary process forks several worker processes, and all of them run the same server code.
This can still be useful if you're reading some older Node service, running something on a single VM, or you want one Node entry file to start multiple OS processes on the same machine.
But cluster stays local to that machine only. Every worker has its own JavaScript heap, event loop, module cache, open client connections, normal process state, all of it. Worker 1 doesn't automatically share variables with worker 2 just because both came from cluster.fork(). The primary can create workers and coordinate server handles, but it doesn't turn all those processes into one shared-memory program. They are still separate processes with separate memory, that's all.
Cluster is also built from the same child-process pieces we already covered earlier. cluster.fork() starts Node child processes. Those workers get an IPC channel back to the primary. Server handles and socket handles can travel through that IPC channel using the same handle-passing support available with Node child processes. Cluster mostly just packages all that into a server-focused API.
The main concern here is incoming server traffic. You have one primary process, several workers, and one listening address that all those workers need to participate in. Because of that, cluster also gives you worker lifecycle APIs for starting, disconnecting, replacing, and watching workers, plus scheduling behavior for deciding which worker gets a connection.
So most of this module really comes down to two jobs - managing a group of worker processes, and coordinating server traffic between them.
Primary and Worker Processes
node:cluster splits your program into one cluster primary process and one or more cluster worker processes.
The primary is just the process that started first. It runs your entry file, sees that cluster.isPrimary is true, and usually starts the workers from there. A worker is another Node process created by calling cluster.fork(). By default, that worker runs the same entry file again.
Which explains why cluster code usually has this branch somewhere near the top -
import cluster from "node:cluster";
if (cluster.isPrimary) {
cluster.fork();
cluster.fork();
} else {
await import("./server.js");
}The primary runs the first branch, each worker runs the second one. cluster.isPrimary tells you you're in the primary process, while cluster.isWorker tells you you're inside a cluster worker.
When Node starts a cluster worker, it also sets an internal NODE_UNIQUE_ID environment value before booting that child. Cluster uses that during startup to know the process is a worker. You normally shouldn't build application logic around NODE_UNIQUE_ID though - that's Node-owned metadata. In application code, use cluster.isPrimary and cluster.isWorker only.
Usually the primary branch creates workers and watches what they're doing. The worker branch starts the HTTP server, loads your request handlers, creates database clients, handles actual traffic.
And remember, these are separate OS processes. If you do this in the primary -
let count = 10;the workers don't suddenly get access to that variable. Dream on.
Same with modules. If worker 1 imports some module containing a cache, worker 2 has its own copy of that module and its own cache, in its own memory.
This is probably the first thing that bites people with cluster. Take a request counter -
let requests = 0;
http.createServer((req, res) => {
requests++;
res.end(`${process.pid} ${requests}\n`);
}).listen(3000);Run this with four cluster workers and you don't get one shared request count. You get four separate counters, one per process. Maybe worker 1 has handled 30 requests, worker 2 has 24, worker 3 has 27, worker 4 has 31. Each one is incrementing its own requests variable because each process has its own memory.
If you actually need one service-wide counter, then some service-wide owner has to store it. Maybe the workers report counts back to the primary over IPC. Maybe it belongs in Redis. Maybe your database or some other service holds it. But a plain JavaScript variable inside a worker is that worker's variable only.
When the primary does this -
const worker = cluster.fork();it gets back a cluster.Worker object. That object represents the cluster worker from the primary side. It has an id, a .process property which points to the underlying ChildProcess, IPC methods such as .send(), and lifecycle methods including .disconnect() and .kill().
Inside the worker itself, cluster.worker refers to that worker's own cluster object.
The primary can also inspect cluster.workers, which means an object containing the currently tracked workers, keyed by cluster worker id.
for (const worker of Object.values(cluster.workers)) {
worker.send({ type: "reload-config" });
}This is primary-side code, obviously.
Workers are removed from cluster.workers after they have disconnected and exited. Also don't build shutdown code assuming 'disconnect' and 'exit' will always arrive in some fixed order - watch the events and worker state you actually get, and trust nothing else.
You'll also see two different ids when working with cluster. One is worker.id and the other one is worker.process.pid. They are not the same thing, please don't mix them. worker.id is the id cluster assigned to that worker object, and worker.process.pid is the operating-system process id.
Logs often benefit from having both. The worker id helps when you're tracing what the cluster primary did with a worker, and the PID helps when you're looking at OS process tables, signals, crash information, or logs from other tooling.
You can also pass environment values when starting a worker -
cluster.fork({
ROLE: "http"
});Those values get added to the environment for that worker, while the normal primary environment is inherited also, following the usual child-process behavior. This is okay for small per-worker values such as a role name or a local worker label. Full configuration and secret handling is a bigger topic, doesn't really need to get mixed into the cluster discussion here.
setupPrimary() Controls Workers Created Later
cluster.setupPrimary() lets the primary configure defaults used by later calls to cluster.fork(). So you'd normally call it before creating workers -
import cluster from "node:cluster";
if (cluster.isPrimary) {
cluster.setupPrimary({
exec: "worker.js",
execArgv: ["--enable-source-maps"]
});
cluster.fork({ ROLE: "http" });
}exec changes which file the worker runs. args lets you change the arguments passed to that file, while execArgv changes the Node runtime arguments used when starting the worker process. There are other settings also - stdio, serialization, silent, uid, gid, inspectPort, all that. Worker-specific environment values normally go into cluster.fork(env).
One detail which is easy to miss - these settings apply to workers you create after calling setupPrimary(). They don't rewrite a worker that's already running. If a worker already started with one entry file, one set of runtime flags, and one stdio setup, another call to setupPrimary() is not going to reconfigure that process. It only changes defaults for later workers, that's it.
So technically you can do something like this -
cluster.setupPrimary({
exec: "api-worker.js"
});
cluster.fork({
ROLE: "api"
});
cluster.setupPrimary({
exec: "jobs-worker.js"
});
cluster.fork({
ROLE: "jobs"
});Node allows this. But once your primary starts launching several totally different types of worker, you're writing a local process supervisor now. Cluster can do it, sure, but its server-specific behavior is most useful when several workers are running the same server code.
inspectPort comes up when debugging cluster workers also. If several workers all start with the inspector enabled, they can't all use the exact same inspector port. Node can increment worker inspector ports automatically from the primary debug port, or you can configure inspectPort yourself. The cluster-specific bit to remember is just that the primary decides worker startup configuration before the worker begins running.
stdio and silent follow the child-process behavior too. Normally worker stdout and stderr behave much as you'd expect from Node child processes. If you set -
cluster.setupPrimary({
silent: true
});worker stdout and stderr get piped back to the primary. Handy if the primary wants to collect worker output, or if some test needs to check what workers are printing. But once output is going through pipes, somebody has to read those pipes. A worker continuously writing into piped output can still hit the same backpressure problems we discussed with child processes earlier. Everything connects back, somehow!
serialization controls how normal IPC messages are serialized. Cluster IPC belongs to the same child-process IPC family as child_process.fork(). JSON serialization is the older default behavior, while advanced serialization can carry a wider range of JavaScript values.
For application messages though, boring messages are usually easier to deal with -
worker.send({
type: "reload-config"
});Small objects, known message types, simple payloads. Cluster has its own handle-transfer behavior for server and socket handles - your normal application IPC protocol doesn't need to become clever just because cluster can move handles around.
The Shared Server Handle Path
Now we can come back to that strange listen(3000) behavior from the start.
Normally, calling -
server.listen(3000);means the current process asks for listening state on port 3000. Cluster workers don't follow that exact path. The JavaScript call still happens inside the worker, but cluster gets involved before the actual listening setup is completed. The worker sends a request to the primary over the cluster IPC channel, and the primary creates or finds the server handle associated with that listen address.
Roughly like this -
worker server.listen(3000)
-> worker asks primary over cluster IPC
-> primary looks up or creates server handle
-> worker gets connected to that clustered listener
-> incoming connections reach workersThe worker still owns its own http.Server or net.Server JavaScript object. That object lives inside that worker's memory. What the primary coordinates is the listening handle associated with the server address. And no, this does not mean worker memory became shared. Only the server-handle setup is being coordinated, nothing else.
Connection handling can work in two ways depending on cluster scheduling policy. With round-robin scheduling, the primary accepts incoming connections and passes the accepted socket handles to workers. With the operating-system scheduling path, the primary creates the listening socket, gives workers access to that listening handle, and then workers accept connections from the operating system themselves.
So when the first worker asks to listen on some address, the primary creates or finds the backing handle for it. When another worker makes the same listen request, cluster connects that worker to the same clustered server address instead of trying to do another unrelated bind. That's why all your workers can contain this -
server.listen(3000);and you still end up exposing one service on port 3000. Magic? No, just the primary doing the coordination.
There are a few consequences from this which are easy to miss.
If every worker runs server.listen(3000), port 3000 is the cluster's service address. Worker 1 doesn't own one private port 3000 and worker 2 own another private port 3000 - they're both participating in the same clustered listener. So if you want every worker to start its own private admin server or debug listener, each worker needs a different port.
Port 0 also behaves a little differently once cluster is involved. Normally server.listen(0) asks the operating system to choose some available ephemeral port. In cluster, once the primary has chosen that port for the first clustered listen request, other workers making the same request participate in that same clustered address. They don't each get their own random port. If you actually want one random private listener per worker, you have to arrange that yourself instead of having every worker make the same clustered listen request.
File descriptor listening has another cluster-specific detail. Suppose a worker does this -
server.listen({
fd: 7
});Since the listen request is being coordinated through the primary, descriptor 7 is interpreted from the primary process. And remember, file descriptor tables belong to each process separately - fd 7 in the worker is not automatically the same OS resource as fd 7 in the primary. This becomes relevant when another parent process or service manager opens sockets before Node starts.
After the connection reaches a worker, the rest of the server behavior looks normal from that worker's point of view. The worker's server object emits the usual events. The HTTP parser runs there. Your request callback runs there. Your application code runs there. The response is written from there, and that worker owns the per-connection state associated with the socket it received.
Cluster mainly changes how the connection gets from the shared listening address to one worker. That's the whole trick, really.
How Many Workers?
You'll often see cluster examples doing something like this -
import os from "node:os";
const workerCount = Math.min(
os.availableParallelism(),
4
);
for (let i = 0; i < workerCount; i++) {
cluster.fork();
}os.availableParallelism() is the current Node API for getting an estimate of how much parallelism your program can use by default.
Older examples very commonly use -
os.cpus().lengthand you'll still see that all over old cluster code.
os.cpus() reports logical CPU information. os.availableParallelism() is the API Node now points application code toward when deciding a default level of parallel work. (So when you find os.cpus().length sitting in some 2016 repo, now you know what to replace it with.)
But don't take the number it returns and assume that's automatically the perfect worker count for your server. It's an estimate only.
Maybe every worker opens 20 database connections. Maybe your app is memory heavy. Maybe some native addon blocks for long periods. Maybe you're running inside a container with CPU limits. Maybe four workers perform better than eight for your actual workload. It happens, no?
Cluster can start the processes. It can't tell you what worker count gives your service the best result - that part you have to measure yourself, there is no shortcut for it.
And why did I cap the example at four? Because there's no reason for a little sample program to spawn 16 or 32 workers on somebody's laptop just to prove that cluster.fork() works. Your laptop did nothing wrong.
Listen Addresses Are Part of the Server Identity
The hostname you pass to listen() also changes which listener you're asking for.
For example -
server.listen(3000, "127.0.0.1");and -
server.listen(3000, "::");are different bind targets. 127.0.0.1 is IPv4 localhost, :: is IPv6 everything. Not the same listener at all.
That difference already exists without cluster. Cluster just has to preserve it, because the primary needs to know which listen requests belong to the same server handle.
So if different workers call listen() with different hostnames, ports, or other address details, they may be asking the primary for different listeners - which means cluster will happily create separate handles for each, and your "one server" quietly became two or three.
Same idea with Unix domain socket paths and Windows named pipes. Those paths become part of the listen target also. Cluster can coordinate server handles for them, but normal operating-system rules still apply. If a stale Unix socket file is blocking a bind, cluster doesn't somehow bypass that. If the OS rejects the address, the bind still fails. Cluster is not magic, and I keep saying this only because it keeps being true.
Cluster changes which process coordinates the listening handle. The operating system still owns the actual networking resources.
And that's really the part to carry forward from this section.
cluster.fork() gives you separate Node processes, not shared JavaScript memory. Those workers talk to the primary over IPC. When several workers run the same server.listen() call, cluster coordinates that request through the primary, so those workers can all participate in one listening address.
After a connection reaches one worker, normal server code continues inside that worker itself.
Once you understand those few pieces, cluster stops looking quite so weird. Multiple processes all calling listen(3000) still looks suspicious the first time, sure. But now you know why Node allows it, and which process is actually coordinating the server handle.
Connection Distribution
Connection distribution is the policy that decides which worker receives a new connection. Cluster has two scheduling modes.
cluster.SCHED_RR means round-robin scheduling. The primary accepts incoming connections and assigns them to workers in rotation, with internal checks around worker state. On Unix-like platforms this is the normal default in modern Node. Windows has historically used the operating-system scheduling mode by default. (Windows people, you got the other one. Sorry.)
cluster.SCHED_NONE means the primary gives workers access to the listening handle and leaves accept scheduling to the operating system. The kernel wakes workers as connections arrive. Node's docs themselves call out that this can become uneven in practice. The operating system's scheduler owns the immediate accept path, and Node loses the primary-side rotation point.
Set the policy before workers are created -
import cluster from "node:cluster";
cluster.schedulingPolicy = cluster.SCHED_RR;
cluster.setupPrimary();cluster.schedulingPolicy freezes once cluster.setupPrimary() runs or the first worker is forked. The environment variable NODE_CLUSTER_SCHED_POLICY can also set it before the process starts. Valid values are rr and none.
NODE_CLUSTER_SCHED_POLICY=rr node server.jsAfter that early setup point, changing the JavaScript property is the wrong level of control. Existing handles and workers already have their distribution path fixed. Treat scheduling policy as startup configuration only.
The two paths have different ownership -
SCHED_RR:
client -> primary accept -> selected worker socket
SCHED_NONE:
client -> shared listen handle -> worker acceptRound-robin gives the primary a clear selection point. Operating-system scheduling gives the kernel the selection point. Both paths still end with a socket owned by one worker process. Once that worker has the connection, all application state for that connection stays there until the socket closes or user code passes it somewhere else.
Cluster scheduling works at the connection layer. If request 1 and request 2 arrive on separate TCP connections, they may land on different workers. If a request depends on process-local session state, the design has already made the worker choice part of correctness - which means you either store session state outside the worker, or arrange connection affinity at a layer that owns routing. Chapter 13 and Chapter 35 take that further.
Keep-alive and upgraded connections add a smaller but common wrinkle. A worker owns the socket it received. Multiple HTTP requests sent over the same keep-alive connection stay on that worker, because they share that one socket. A WebSocket upgrade also stays on the worker that received the upgraded connection. Cluster's scheduler gets a decision at connection assignment time only. Later application messages ride the existing socket.
That point has real load implications - a small number of long-lived connections can pin work to a subset of workers. Round-robin over new connections can still leave application load uneven when the cost per connection varies. Established sockets stay with their current worker, period.
Scheduling policy also changes how failures look. In round-robin mode, the primary sees every accepted connection before a worker gets it, which means the primary has one central place to avoid disconnected workers. In operating-system scheduling mode, workers accept directly, so the kernel's wakeup behavior controls the next connection. Both modes still need worker-level error handling, because a socket can fail after assignment also.
Cluster's scheduler is process-local. It sees workers inside one primary only. It has no view of other hosts, other containers, upstream retries, or platform load balancers. If a platform is already spreading traffic across replicas, cluster becomes one more local layer inside each replica. That can be acceptable, but it should be deliberate, because every layer changes where logs, connection counts, and restart behavior show up.
Lifecycle Events and Draining
The primary controls worker lifecycle by creating workers, tracking events, disconnecting them, and sometimes replacing them. Cluster exposes enough events to write a local supervisor loop, while leaving production rollout policy to the later deployment chapters.
Common primary events -
cluster.on("online", worker => {
console.log(`worker ${worker.id} online`);
});
cluster.on("listening", (worker, address) => {
console.log(worker.id, address.port);
});'online' means the worker process has responded after fork. 'listening' means a worker's server has reported a listening address through cluster. One thing to note - a worker can be online before it is ready to accept application traffic. Health checks and readiness belong to deployment code, but you'll want this distinction when reading cluster logs. "Is it online" and "is it ready" are two different questions.
Worker exit replacement usually starts with the 'exit' event -
cluster.on("exit", worker => {
if (!worker.exitedAfterDisconnect) {
cluster.fork();
}
});worker.exitedAfterDisconnect helps separate planned drains from crashes or external termination. The primary can replace unexpected exits. And please add rate limits in real services. A bug that crashes every worker should become a visible failure, while an endless spawn loop just hides the cause and burns CPU. Been there, fixed that.
cluster.disconnect() starts a cluster-wide disconnect from the primary -
process.on("SIGTERM", () => {
cluster.disconnect(() => process.exit(0));
setTimeout(() => process.exit(1), 10_000).unref();
});The method calls .disconnect() on each worker in cluster.workers. Workers close their servers, stop accepting new connections, and exit once existing work drains and the IPC channel closes. The callback runs after workers have disconnected and internal handles are closed. The timeout guard gives the process a bounded shutdown path - if draining takes more than ten seconds here, we exit anyway, because sometimes you just have to go.
Worker code can also react to disconnect -
process.on("disconnect", () => {
server.close(() => process.exit(0));
});That example assumes server is in scope, yes. A worker with multiple servers, timers, database clients, or long-lived sockets needs its own close sequence. Cluster only triggers the worker disconnect path. Application resources stay application-owned - cluster is not gonna close your database connections for you. (Wouldn't that be nice though.)
Signals usually arrive at the primary in cluster deployments, especially when the service manager starts the primary process. The primary then decides whether to call cluster.disconnect(), send messages to workers, or forward signals. A signal sent directly to a worker affects that worker process itself. Signal handling from Chapter 5 still applies per process.
Exit codes are per worker too. The primary receives code and signal values on worker exit events. Treat those as process facts, not task facts. A worker may have been handling many requests when it died. A nonzero exit tells you the process failed or was killed, that's all. Request-level impact needs application logs, access logs, traces, or client retry data.
Draining gets tricky when workers hold long-lived sockets. server.close() stops accepting new connections and waits for existing connections to close, under Node's server semantics. A long-lived WebSocket can keep a worker alive for hours if application code leaves it open. For that reason cluster shutdown code needs a maximum drain window, always.
Worker-local cleanup has to run in the worker itself. The primary can request disconnect, fine. But the worker closes its own database clients, timers, and sockets, because those objects live in the worker's heap - the primary cannot even see them. A clean worker usually listens for disconnect or an application shutdown message, stops accepting new work, closes its own resources, then exits. The primary watches the result and enforces the deadline.
There is a sharper operation also - worker.kill([signal]). That sends a signal to the worker process. It belongs at the end of a drain deadline, or in a local failure policy where graceful shutdown has already failed. The tradeoff is data loss inside the worker - any in-flight request, buffered response, or local task dies with the process. Use it knowingly.
Replacement has the same limitation. Forking a new worker replaces process capacity only. In-memory sessions, in-flight requests, and local queues from the old process need a recovery owner outside that old worker. The new worker starts completely fresh.
What's Actually Going On Inside
Cluster is easiest to reason about when you separate JavaScript objects from libuv handles and OS sockets.
The primary process starts as a normal Node process. It loads your entry module, imports node:cluster, and reaches the cluster.isPrimary branch. Each cluster.fork() call prepares an environment for a worker - including the worker id marker - then starts another Node process using the usual child-process fork mechanics. The worker starts from the same entry file by default. setupPrimary({ exec }) can give future workers a different entry file. Early bootstrap code reads the environment marker and sets the cluster module's worker-side state.
At that point the primary has a cluster.Worker object and a ChildProcess. The worker has its own process object, event loop, V8 isolate, heap, module cache, libuv loop, and IPC endpoint. Messages between the two processes cross the IPC channel. Values sent as messages are serialized. Handles sent through the channel use the handle-passing path that Node exposes for child processes.
The server path starts in worker JavaScript. http.createServer() creates an HTTP server object in the worker, holding request listeners and server state. The kernel socket appears later only. server.listen(3000) enters Node's server listen implementation. In a clustered worker, Node's cluster integration intercepts the listen operation for supported server types and asks the primary for a handle matching that address and listen options.
The primary keeps a mapping for those clustered handles. The key comes from the listen target - address, port, address type, file descriptor details, and related options. When no handle exists for that key, the primary creates one. For a TCP server, that eventually means a libuv TCP handle bound to the address with a listen backlog. When a handle already exists, another worker gets attached to the existing clustered handle state rather than creating a second independent bind for the same address. Two workers, one socket. That's the whole arrangement.
Supported handle types count also. Cluster was built for server-style networking. TCP servers are the common path. Named pipes and Unix domain sockets have platform-specific behavior. UDP has its own cluster behavior through datagram sockets. Arbitrary native resources need their own process model and ownership rules - cluster is not gonna hold those for you.
With SCHED_RR, the primary keeps the accept point. Incoming connections wake the primary's listening handle. The primary accepts a connected socket, chooses a worker, and sends the socket handle over IPC. The worker receives the handle and attaches it to its server object as an incoming connection. From there, the worker's normal server machinery runs - HTTP parsing for HTTP servers, 'connection' events for raw TCP servers, request callbacks, stream reads and writes, socket close handling, all of it.
The primary's choice is round-robin at the cluster scheduling level. Node also tracks worker state, so it can avoid sending connections into workers that are disconnected or otherwise unavailable for new work. The exact internal data structures can move between Node releases, so keep the conceptual ownership stable instead - primary accepts, primary selects, worker handles.
With SCHED_NONE, the primary creates the listening socket and passes the listening handle to workers. Each worker then has the ability to accept from that shared underlying listen state. The kernel decides which process wakes for a connection. The primary has coordinated handle creation, and then it steps out of per-connection selection. That mode reduces primary involvement in each accept, and it also gives up Node's user-space rotation. Take it or leave it.
The phrase "shared server handle" can hide several layers, honestly. The JavaScript http.Server object lives per worker. The primary's internal handle table lives in the primary. The kernel socket state lives below both processes. The accepted connected socket ends up with one worker. Those four states can change at different times - a worker can have a JavaScript server object before the primary creates the handle, the primary can have the listening handle before a worker receives traffic, and a connected socket can remain alive in a worker after the primary has already started disconnecting the cluster.
Several bugs come from exactly that assumption, i.e one layer implies the other. A worker being online says only that the child process completed enough bootstrap to respond. The server handle has its own state. A 'listening' event says a server reached a listen state. Downstream dependencies have their own state. A disconnected IPC channel says the parent-child control channel is gone. Socket drain has its own state inside each server and connection. Cluster gives you process events, handle events, and IPC events - your application still has its own readiness and shutdown state on top.
Cluster also inherits all the usual child-process costs. Every worker starts a full Node runtime. Every worker loads modules. Every worker opens its own outbound sockets when the worker code creates them. A cluster with eight workers can create eight database pools, eight cache clients, and eight copies of large in-memory data. The primary can coordinate inbound server handles, sure. Outbound resource usage multiplies in the workers, and nobody is gonna stop that for you.
That multiplication is often the real scaling limit on a single host. CPU parallelism is one input. Memory, file descriptors, upstream connection limits, and startup time count just as much. Cluster exposes process count as a local knob, that's it. A service-level capacity model needs measurement around the full request path.
Session Affinity and Long-Lived Connections
Cluster distributes connections. Application-level routing can need more than that.
Process-local session storage is the common failure here. User A logs in on worker 1. The next TCP connection lands on worker 3. Worker 3 has its own memory, and worker 1's in-memory session object stays in worker 1 - where it helps nobody. The fix is architectural i.e move session state out of worker memory, or put affinity at a routing layer that owns connection placement.
The same limitation shows up with per-connection realtime state. A WebSocket upgrade enters one worker and stays there. Rooms, subscriptions, presence, fanout, replay - all that belongs to the realtime chapters. For cluster, the local fact is enough - a message held in worker memory is local to that worker, full stop.
HTTP keep-alive can make the load look uneven also. A worker with many idle keep-alive sockets may look busy at the descriptor level but light at the request level, while a worker with fewer connections may be doing more CPU work. Cluster schedules connection assignment. Business cost lives above that scheduler.
Long drains follow from the same mechanics. A worker with long-lived sockets can remain alive long after cluster.disconnect() starts. Production code needs a drain deadline and an application-level close path for sockets that can outlive ordinary request-response traffic.
And keep the deferred pieces deferred - sticky sessions, realtime fanout, cross-process rooms, and platform load-balancer affinity are all later topics. Cluster just gives you enough vocabulary to recognize where the line sits.
The safe local rule is simple. Anything that must survive worker replacement belongs outside worker memory. Anything that must reach every connected client needs a cross-worker path. Anything that must route a reconnect to the same process needs an affinity owner above cluster, or a protocol that tolerates a different worker. Memorize that one rule and half your cluster bugs simply stop existing.
Where Cluster Fits Now
Cluster still earns its place in Node because many services use it, many examples still show it, and the API packages a real process model. It is a local primitive - one Node primary and several Node workers on one host.
It's a good reading skill also. When you open some older service and see cluster.isMaster, translate it in your head to the modern cluster.isPrimary name. cluster.isMaster and cluster.setupMaster() are deprecated aliases. New code should use cluster.isPrimary and cluster.setupPrimary(). The old names just tell you the code predates Node's terminology change, while the modern API names remain available.
Cluster is a workable compatibility choice when one process manager starts one Node entry point and the application wants multiple local server processes. It can also be useful in tests that need the exact behavior of a cluster-based service, or in migration work where changing the process model would create too much risk at once.
For new production systems, the main scaling unit usually moves outside node:cluster. A process manager can run several Node processes. A container platform can run several replicas. A platform load balancer can distribute connections across instances. Those tools own process restart, health checks, rolling deploys, placement, resource limits, and cross-host routing. Cluster owns local worker creation and local handle distribution - much smaller job.
Worker threads are a different answer. They keep work inside one process with separate JavaScript execution contexts and worker-thread messaging, useful for CPU-bound JavaScript where you want in-process concurrency. Child-process pools are another answer, useful for external executables, process isolation, and task-style work. Cluster is narrower than both i.e several Node processes sharing server listening behavior.
The practical decision is usually plain enough. Use cluster when you need to maintain, or intentionally run, the cluster process model. Use platform replicas when the service is already deployed behind a platform that manages processes and routing. Use process pools for bounded task execution. Use worker threads for in-process CPU work where their memory and messaging model fits.
Cluster's best trait is also its limit, funny enough. It makes several worker processes look like one listening Node service from the outside. Inside the program, they remain several processes - no shared memory, nothing. Keep that fact visible and the whole module becomes readable instead of mysterious.