Article View

Scroll down to read the full article.

The Ghost in the Socket: EADDRINUSE on Ephemeral Ports with Node.js Cluster & cgroups

calendar_month August 18, 2026 |
Quick Summary: Fix elusive EADDRINUSE/EAGAIN errors in Node.js cluster workers binding to ephemeral ports under cgroup v1 and specific Linux kernels. Kernel netw...

Alright, listen up. If you're here, you've probably spent weeks debugging a Node.js cluster setup where workers occasionally, inexplicably, refuse to start. They just puke out EADDRINUSE or EAGAIN errors when trying to bind to an ephemeral port (server.listen(0)). But here's the kicker: nothing else is listening on that port. You've checked. You've sworn. You've run netstat and lsof until your fingers bled. This isn't your typical port conflict. This is deeper. This is a ghost in the machine.

I've seen this exact hellscape unravel on critical production systems. It leads to cascading failures, degraded service, and engineers questioning their life choices. Let's kill this thing dead.

A complex
Visual representation

The Phantom Port Problem: Symptoms

Your Node.js cluster spawns workers. Most come up fine. But randomly, usually under moderate to high load, or after a rapid deployment cycling processes, some workers die immediately with:

Error: listen EADDRINUSE: address already in use :::0
    at Server.setupListenHandle [as _setupListenHandle] (node:net:1483:16)
    at Server.listen (node:net:1589:10)
    at Object.<anonymous> (/path/to/your/app/worker.js:10:13)
    ...

Or sometimes, even more bafflingly:

Error: listen EAGAIN: try again :::0
    at Server.setupListenHandle [as _setupListenHandle] (node:net:1483:16)
    at Server.listen (node:net:1589:10)
    ...

Restarting the failing worker often works. Sometimes it takes multiple restarts. It's nondeterministic, which is the worst kind of evil.

The Specific Environment Trigger

This isn't a universal problem. It requires a confluence of specific, annoying factors. Pay close attention to your setup:

Component Versions/Configurations Notes
Operating System Linux Kernel 4.15.x - 4.19.x Particularly prevalent on older Ubuntu (e.g., 18.04 LTS), CentOS 7/RHEL 7 with specific kernel updates.
Node.js Version Node.js 12.x, 14.x, 16.x This is largely kernel-dependent, not Node.js itself, but these versions hit the sweet spot of common deployment.
Container Runtime Docker, containerd, Kubernetes (using cgroup v1) Specifically when cgroup v1 controllers, especially net_cls or net_prio, are actively used or even just enabled for the container/process.
System Network Config net.ipv4.ip_local_port_range Narrow ranges (e.g., 32768-36000) exacerbate the issue by increasing contention.

Initial Sanity Checks (You've Done These, I know)

Before you tell me you checked, I'm telling you again:

  • Double-check listening processes: sudo lsof -i :0-65535 | grep LISTEN. Filter for your service. Verify no *other* service is actually bound.
  • TIME_WAIT sockets: Check netstat -an | grep TIME_WAIT. While not the direct cause of EADDRINUSE for listen(0), excessive TIME_WAIT can exhaust other resources.
  • ulimit -n: Ensure your file descriptor limits are high enough. This usually manifests as EMFILE, but it's worth a glance.

If those check out, and you're still seeing it, then you're in my world.

The Real Hunt: Tracing the Kernel Dance

This isn't an application-level bug. This is a kernel scheduler/networking subsystem interaction issue. Get your strace game ready.

Step 1: Confirming the System Call Failure

Attach strace to a failing Node.js worker immediately as it tries to start. You'll need to catch it fast. Find your Node process ID (PID) and attach:

sudo strace -fp <NODE_PID> -e trace=network,socket,bind,listen -yy

You'll see something like this for the bind call:

...
socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) = 3
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET6, sin6_port=htons(0), inet_pton(AF_INET6, "::", &sin6_addr), 0}, 28) = -1 EADDRINUSE (Address already in use)
...

Bingo. The kernel itself is reporting EADDRINUSE. Or sometimes EAGAIN if the internal port allocation logic is struggling to find a free port within the allowed range due to contention or temporary exhaustion.

A rusty
Visual representation

Step 2: Inspecting cgroup Configuration

The cgroup v1 net_cls and net_prio controllers are the prime suspects. Even if you're not explicitly shaping traffic, their mere presence and activation can sometimes trigger this. Check your container's cgroup mounts:

cat /proc/self/cgroup | grep net_cls
cat /proc/self/cgroup | grep net_prio

If you see output for these, and especially if their respective cgroup.procs files contain your Node process, then we're on the right track. Often, Kubernetes or Docker environments (especially older ones or specific setups) will enable these by default even if not actively utilized.

Step 3: Kernel Version Check

uname -a. If you're on a 4.15-4.19 kernel, you've likely hit a known, subtle bug. These kernels had some reworks in the ephemeral port allocation logic and sock_hash management, particularly when interacting with cgroups, that could lead to transient misidentifications of port availability.

The Root Cause

This is a nasty, obscure race condition within the Linux kernel's networking stack, specifically affecting ephemeral port allocation (IP_LOCAL_PORT_RANGE) when combined with certain cgroup v1 configurations (namely net_cls or net_prio controllers) on kernel versions roughly between 4.15 and 4.19. When a Node.js cluster rapidly forks new workers, each requesting an ephemeral port (server.listen(0)), the kernel's internal hash table (sock_hash) used for tracking active sockets can get into a transient, inconsistent state. Under the added contention and specific locking mechanisms introduced by active net_cls controllers, a recently released ephemeral port might still have a stale, "in-use" entry in the hash table during the brief window another process tries to bind to it. This leads to EADDRINUSE. The EAGAIN variant is even more insidious, often implying the kernel's internal port allocator itself is temporarily unable to find a suitable port due to this contention or a momentary lock-up in its search mechanism. This is particularly problematic in environments focused on high-frequency, low-latency operations where process lifecycle is critical, like those described in Sub-Microsecond Supremacy: Engineering Algorithmic Trading for Absolute Latency Dominance, where even milliseconds of delay can be catastrophic.

The Fix (Don't Blame Me, Blame the Kernel)

The proper, long-term fix is to upgrade your Linux kernel to 5.x or newer. Most of these bugs were addressed in later kernel releases that refined socket hash table management and cgroup interactions. But if you're stuck on an older kernel, perhaps on a legacy cloud instance or due to compliance, we need a workaround.

The workaround involves telling Node.js to explicitly retry binding a few times if it hits this specific error. Node.js's built-in cluster module doesn't handle this gracefully, but we can wrap the worker's listen call.

Modify your Node.js worker entry point. Instead of a direct server.listen(0), implement a retry mechanism for EADDRINUSE or EAGAIN specifically for ephemeral port binding. It's ugly, but it works.


const net = require('net');
const server = net.createServer((socket) => {
  // Your server logic here
  socket.end('Hello from worker!\n');
});

const MAX_RETRIES = 5;
let attempts = 0;

function startServer() {
  server.listen(0, () => {
    const port = server.address().port;
    console.log(`Worker ${process.pid} listening on port ${port}`);
  });
}

server.on('error', (err) => {
  if (err.code === 'EADDRINUSE' || err.code === 'EAGAIN') {
    attempts++;
    if (attempts <= MAX_RETRIES) {
      console.warn(`Worker ${process.pid} failed to bind on attempt ${attempts}/${MAX_RETRIES} with ${err.code}. Retrying in 100ms...`);
      setTimeout(() => {
        server.close(() => { // Ensure the server is fully closed before retrying
            startServer();
        });
      }, 100);
    } else {
      console.error(`Worker ${process.pid} failed to bind after ${MAX_RETRIES} attempts. Giving up. Error:`, err);
      process.exit(1); // Crucially, exit to allow the cluster manager to respawn
    }
  } else {
    console.error(`Worker ${process.pid} encountered unhandled server error:`, err);
    process.exit(1);
  }
});

startServer();

This snippet provides a robust retry loop for these specific ephemeral port binding failures. The setTimeout and server.close() ensure the system has a moment to clear any lingering kernel state before the next bind attempt. This kind of resilience is crucial, especially when dealing with unpredictable kernel behaviors, much like mitigating issues in fs.watch on NFS that can create "event black holes".

Final Thoughts

This problem is a testament to how deep you sometimes have to go when troubleshooting. It’s rarely your code; it’s the layers beneath. Understanding the interaction between your runtime, the kernel, and containerization technologies is paramount. Don't waste time looking at application logic when the kernel is playing tricks on you. Patch your kernels, or prepare for battle with workarounds.

Discussion

Comments

Read Next