Why Doesn't Listening on the Same Port in Multiple Node.js Cluster Workers Throw EADDRINUSE?

Why Doesn't Listening on the Same Port in Multiple Node.js Cluster Workers Throw EADDRINUSE?

September 26, 2022

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. If process.env.NODE_UNIQUE_ID is undefined, then isPrimary is true.

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

  1. Primary binds port 8000 once via RoundRobinHandle.
  2. Each worker calls listen(8000), which sends queryServer over IPC.
  3. Primary responds with a fake handle (no OS socket, just a JS object).
  4. Worker sets onconnection on the fake handle — this is what your http.createServer callback ends up attached to.
  5. 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 onconnection fires 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;
}