Four processes, one port, and a file descriptor in the post
Part 10 gave you a second runtime inside one process. This part gives you a second process — and immediately runs into a problem that has no equivalent at the thread level.
Two processes cannot both bind() port 3000. Part 8 showed exactly why: the second bind returns EADDRINUSE, and it is the kernel refusing, not Node. So how do four worker processes end up serving one port?
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
} else {
http.createServer(handler).listen(3000); // four processes, same port
}Before the answer, the same orientation Part 10 opened with — because the thing that changes here is one level further out. Part 10 added a runtime inside your process; cluster adds whole processes beside it.
epoll_wait occupies no core.os.availableParallelism() counts. A large bare-metal server may carry a second package, a second socket — a word unrelated to the network sockets of Parts 0, 5, 8 and 9.const cluster = require("node:cluster");
const http = require("node:http");
const os = require("node:os");
if (cluster.isPrimary) {
// the primary forks and supervises; it never serves a request
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
cluster.on("exit", (w) => console.log(`worker ${w.process.pid} died`));
} else {
// every worker runs THIS branch, in its own process, on the same port
http.createServer((req, res) => res.end(`served by ${process.pid}\n`))
.listen(3000);
}$ curl localhost:3000 -> served by 48213 $ curl localhost:3000 -> served by 48214 $ curl localhost:3000 -> served by 48215
Three requests to one port, answered by three different processes. Note the shape: the file runs once per process and the branch decides which role each copy plays — the same pattern as Part 10's isMainThread, one level out. The primary is a supervisor, not a server.
The answer is that they do not each bind it. In the default mode exactly one socket is ever created — by the primary — and the file descriptor for it is sent to the workers over a unix domain socket, using a kernel facility built for precisely this. The listen(3000) in a worker is a polite fiction: it is intercepted and turned into a request for a descriptor the primary already holds.
An fd is a small integer indexing a per-process table, as Part 5 said. Sending one to another process does not send the number — it makes the kernel install the same underlying open file description in the receiver's table, under whatever number happens to be free there.
Everything that was shared is now copied
Part 10 moved the JS heap from the shared column to the private one. A process moves everything else with it. This is Part 5's per-thread/shared table, redrawn one level up.
SharedArrayBuffer cannot cross here — the one door Part 10 left open is closed.The trade is exact and it is the whole reason both features exist. A worker thread shares the process and cannot survive its death. A cluster worker survives anything the others do and shares almost nothing.
The kernel installs it; the number is meaningless
The mechanism is a POSIX facility older than Node: a unix domain socket can carry ancillary data of type SCM_RIGHTS, and that payload is a list of file descriptors. The kernel reads it, finds the open file description each number refers to in the sender, and installs a reference to the same description in the receiver.
Node does not expose this directly, but it is not hidden either: child.send(msg, socketOrServer) takes a handle as its second argument, and that is the same machinery. cluster is a thin layer over it.
Who decides which worker gets the connection
Once several processes hold one listening socket, something has to decide which of them serves the next connection. Node offers two answers, and they behave differently under load.
SCHED_RR — the default everywhere except Windows — keeps the accept in the primary. The primary accepts each connection and hands the connected socket to a worker in turn, which gives an even distribution at the cost of a round trip through the primary.
SCHED_NONE gives every worker the listening fd and lets them all call accept(). The kernel picks the winner, which is faster and less even — and on Linux, historically, quite badly uneven.
Several real sockets, one port, hashed by connection
There is a third arrangement that cluster does not use but which underpins a lot of modern deployment: SO_REUSEPORT lets several processes each create their own listening socket on the same port, and asks the kernel to distribute incoming connections between them.
The count of accept queues is the thing to hold on to. One queue means the workers are competing for the same backlog from Part 8; four queues means one slow worker fills only its own, and the kernel keeps handing it connections anyway because the hash does not know it is busy.
Memory and warmup multiply; the machine does not
Part 10's workers were expensive because each carried a V8 isolate. A cluster worker carries that and a process: its own address space, its own copy of every module, its own JIT state, and its own share of the page cache pressure.
The shape to notice is that throughput does not keep climbing. Up to the core count each worker gets a core and the line is roughly linear; past it, the workers are time-slicing against each other, and every extra one adds memory and context switches while adding no capacity.
Copy-on-write helps less than people expect. The pages are shared after fork(), but V8 writes to almost all of them as it warms up — every inline cache and every JIT-compiled function is a write that unshares a page.
Anything in memory is now per worker
This is the part that breaks working code. Every in-process convenience becomes wrong the moment there are four processes, and none of them fails loudly.
The rule is blunt: with cluster, anything you keep in memory is per worker. State that must be shared belongs outside the process — Redis, the database, or a message the primary relays — and "outside the process" is the price of the isolation you bought.
One crash, one worker, one fork
A worker thread cannot survive a segfault, an OOM or a hard process.exit — it shares the process, so the process goes. A cluster worker can, and that is the strongest argument for paying its costs.
cluster.on("exit", (worker, code, signal) => {
console.error(`worker ${worker.process.pid} died (${signal || code})`);
cluster.fork(); // replace it
});The naive version has a well-known failure: if the crash is deterministic — a bad deploy, a config error — this loop forks a fresh worker to die immediately, forever, at whatever rate the machine allows. Real supervisors add a backoff and a cap, which is a large part of what a process manager gives you over these three lines.
Restarting is only a strategy if the failure is transient. A crash loop is the same shape as Part 4's starvation: a process at 100% CPU, achieving nothing, with nothing in the logs saying it is stuck.
Replace the workers while the socket stays open
Because the listening socket belongs to the primary and not to any worker, workers can be replaced without ever closing it. Connections that arrive mid-reload queue in the accept queue — Part 8's backlog — rather than being refused.
close() on the listener. Arrivals sit in the accept queue.kill().Three questions that decide it
Parts 4, 10 and 11 have now given you three different answers to "my server is slow", and picking between them is not a matter of taste.
epoll_wait and one thread serves thousands of connections. Adding threads or processes here buys nothing and costs memory.Ten claims, three of them true
These are the beliefs that produce the outages. Several are true of some other stack, which is exactly why they survive.
Nearly every wrong take here assumes the workers share something. They share the machine and whatever descriptors were explicitly passed, and nothing else.
If you remember nothing else
Cluster does not make one server faster. It runs several servers behind one port and gives you a supervisor.
SCM_RIGHTS. A worker's listen() is intercepted, not honoured.SO_REUSEPORT gives each its own and lets the kernel hash between them.That closes the arc the series set out to draw. Part 5 found the one thread; Parts 8 and 9 showed how far it goes on its own; Part 4 opened the loop that drives it; Parts 10 and 11 are the two honest ways to get a second one — and both of them are more expensive than not needing one.