Why Doesn't Listening on the Same Port in Multiple Node.js Cluster Workers Throw EADDRINUSE?
The Node.js docs show this pattern without any explanation of why it works:
const cluster = require("node:cluster");
const http = require("node:http");
const numCPUs = require("node:os").cpus().length;
const process = require("node:process");
if (cluster.isPrimary) {
console.log(`Primary ${process.pid} is running`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on("exit", (worker, code, signal) => {
console.log(`worker ${worker.process.pid} died`);
});
} else {
http
.createServer((req, res) => {
res.writeHead(200);
res.end("hello world\n");
})
.listen(8000);
console.log(`Worker ${process.pid} started`);
}
Everyone knows that listening on a port twice throws:
man listen
listen() will fail if:
[EINVAL] socket is already connected.
And indeed, without cluster:
const http = require("node:http");
const numCPUs = require("node:os").cpus().length;
for (let i = 0; i < numCPUs; i++) {
http.Server((req, res) => { ... }).listen(8000);
}
// Error: listen EADDRINUSE: address already in use :::8000
So how does cluster get away with it? Time to read the source.
Tracing http.Server.listen
// lib/net.js
Server.prototype.listen = function (...args) {
// ...
if (options.host) {
lookupAndListen(this, options.port | 0, options.host, backlog, options.exclusive, flags);
} else {
listenInCluster(this, null, options.port | 0, 4, backlog, undefined, options.exclusive);
}
};
Both paths end up in listenInCluster:
// lib/net.js
function listenInCluster(server, address, port, addressType, backlog, fd, exclusive, flags, options) {
exclusive = !!exclusive;
if (cluster === undefined) cluster = require("cluster");
if (cluster.isPrimary || exclusive) {
// Primary process: actually bind and listen
server._listen2(address, port, addressType, backlog, fd, flags);
return;
}
const serverQuery = { address, port, addressType, fd, flags, backlog, ...options };
// Worker: ask the primary for its handle
cluster._getServer(server, serverQuery, listenOnPrimaryHandle);
function listenOnPrimaryHandle(err, handle) {
err = checkBindError(err, port, handle);
if (err) {
return server.emit("error", exceptionWithHostPort(err, "bind", address, port));
}
server._handle = handle;
server._listen2(address, port, addressType, backlog, fd, flags);
}
}
Workers don't bind at all — they ask the primary for its handle. But what about processes that aren't using cluster?
Why Non-cluster Code Still Works
cluster.isPrimary is defined as:
True if the process is a primary. This is determined by
process.env.NODE_UNIQUE_ID. Ifprocess.env.NODE_UNIQUE_IDis undefined, thenisPrimaryistrue.
Without cluster.fork(), NODE_UNIQUE_ID is never set, so every process is considered the primary and calls server._listen2 directly. That's why consecutive listen() calls on different servers in the same process throw EADDRINUSE — they each try to actually bind.
cluster.fork() sets NODE_UNIQUE_ID on worker processes:
// lib/internal/cluster/primary.js
cluster.fork = function (env) {
cluster.setupPrimary();
const id = ++ids;
const workerProcess = createWorkerProcess(id, env);
// ...
};
function createWorkerProcess(id, env) {
const workerEnv = { ...process.env, ...env, NODE_UNIQUE_ID: `${id}` };
return fork(cluster.settings.exec, cluster.settings.args, { env: workerEnv, ... });
}
How a Worker Gets the Primary's Handle
cluster._getServer in a worker sends an IPC message:
// lib/internal/cluster/child.js
cluster._getServer = function (obj, options, cb) {
const message = { act: "queryServer", ...options };
send(message, (reply, handle) => {
if (handle) {
shared(reply, { handle, indexesKey, index }, cb);
} else {
rr(reply, { indexesKey, index }, cb);
}
});
};
The message travels over the IPC channel to the primary. The primary's queryServer handler either finds an existing RoundRobinHandle for that address/port or creates one:
// lib/internal/cluster/primary.js
function queryServer(worker, message) {
const key = `${message.address}:${message.port}:${message.addressType}:${message.fd}:${message.index}`;
let handle = handles.get(key);
if (handle === undefined) {
if (schedulingPolicy !== SCHED_RR || message.addressType === 'udp4' || message.addressType === 'udp6') {
handle = new SharedHandle(key, address, message);
} else {
handle = new RoundRobinHandle(key, address, message);
}
handles.set(key, handle);
}
handle.add(worker, (errno, reply, handle) => {
send(worker, { errno, key, ack: message.seq, ...reply }, handle);
});
}
RoundRobinHandle creates the actual TCP server in the primary and intercepts onconnection:
// lib/internal/cluster/round_robin_handle.js
function RoundRobinHandle(key, address, { port, fd, flags, backlog }) {
this.server = net.createServer(assert.fail);
this.server.listen({ port, host: address, ... });
this.server.once("listening", () => {
this.handle = this.server._handle;
// Replace the connection handler with round-robin distribution
this.handle.onconnection = (err, handle) => this.distribute(err, handle);
this.server._handle = null;
this.server = null;
});
}
The primary binds once. Workers each receive a fake handle via the rr function. When _listen2 is called on that fake handle it sets onconnection, but no real OS bind happens — the primary's RoundRobinHandle.distribute is what actually delivers connections to workers.
The Complete Picture
- Primary binds port 8000 once via
RoundRobinHandle. - Each worker calls
listen(8000), which sendsqueryServerover IPC. - Primary responds with a fake handle (no OS socket, just a JS object).
- Worker sets
onconnectionon the fake handle — this is what yourhttp.createServercallback ends up attached to. - When a connection arrives at port 8000, the primary's round-robin dispatcher picks a free worker and sends the raw socket handle over IPC. The worker's
onconnectionfires and handles the request.
Here's the equivalent model in C — one process binds, forks, and shares the listening socket with children via inheritance:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 4444
int main(){
int sockfd, ret;
struct sockaddr_in serverAddr;
int newSocket;
struct sockaddr_in newAddr;
socklen_t addr_size;
char buffer[1024];
pid_t childpid;
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&serverAddr, '\0', sizeof(serverAddr));
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(PORT);
serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
bind(sockfd, (struct sockaddr*)&serverAddr, sizeof(serverAddr));
listen(sockfd, 10);
while(1){
newSocket = accept(sockfd, (struct sockaddr*)&newAddr, &addr_size);
if((childpid = fork()) == 0){
close(sockfd);
while(1){
recv(newSocket, buffer, 1024, 0);
if(strcmp(buffer, ":exit") == 0) break;
send(newSocket, buffer, strlen(buffer), 0);
bzero(buffer, sizeof(buffer));
}
}
}
close(newSocket);
return 0;
}