Two Processes, One Port, Zero Downtime

SO_REUSEPORT lets many processes bind one port while the kernel spreads connections — zero-downtime restarts with no load balancer.

Share

Zero-downtime deploys usually mean a load balancer in front of two app instances. On a single box, people reach for SO_REUSEADDR and discover it does nothing while the old listener is still alive — the restart drops connections anyway.

There's a different socket flag that actually fixes this: SO_REUSEPORT. It lets several processes bind the exact same address and port, and the kernel spreads incoming connections across all of them.

Why this matters

This is the cheapest zero-downtime mechanism that exists. No load balancer, no extra IP, no socket-passing choreography via systemd or sd_listen_fds. Start the new version of your app while the old one serves traffic, then stop the old one when it's drained.

It also has a performance angle: nginx added a reuseport directive because per-worker listeners eliminate accept contention at high connection rates. One flag, two production wins.

How it works

By default, a second bind() to an in-use address and port fails with EADDRINUSE. SO_REUSEADDR only reclaims sockets stuck in TIME_WAIT — it never touches active listeners.

SO_REUSEPORT changes the rule: any socket that sets the flag before binding may claim the identical address and port. The kernel keeps a list of listeners per port and hashes each new connection's 4-tuple — source IP, source port, destination IP, destination port — to pick one.

Established connections stay pinned to whichever listener accepted them. Kill one process and only its connections die. The survivors keep serving, and the kernel stops routing new connections to the dead socket.

Where this helps

  • Hot restarts: launch v2 alongside v1, both share the port, then send v1 a graceful shutdown once its connections drain.
  • Single-box canaries: run four old processes and one new one — the new binary receives roughly a fifth of new connections.
  • nginx: listen 80 reuseport; gives each worker its own listener instead of one shared socket behind an accept mutex.
  • Crash recovery: systemd restarts your process instantly. No waiting for the port to free up, no EADDRINUSE race.

Watch out

  • Portability: load balancing across identical binds is Linux behavior, kernel 3.9 and newer. FreeBSD wants SO_REUSEPORT_LB; macOS uses the same name with different semantics. Verify per OS.
  • Everyone must opt in: if the first process binds without the flag, it owns the port and every later bind fails.
  • Hashing, not least-connections: there are no weights. One listener can end up holding all the long-lived websockets until they close.
  • Local hijacking: any process on the host can join your port. On shared machines, run the service under a dedicated user.
  • Drain properly: connections queued but not yet accepted by a dying socket can be lost. Stop accepting, finish the backlog, then exit.

Try it yourself

Save this, run it in two terminals, and watch the kernel split the traffic:

# serve.py — run me twice, both bind :8080
import socket, os

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
s.bind(("0.0.0.0", 8080))
s.listen(64)
print(f"pid {os.getpid()} listening on :8080")

while True:
    conn, _ = s.accept()
    conn.sendall(f"hello from {os.getpid()}\n".encode())
    conn.close()

# then: for i in $(seq 1 10); do curl localhost:8080; done

TL;DR

  • SO_REUSEPORT lets many processes bind one port; the kernel hashes each connection's 4-tuple to pick a listener.
  • It enables zero-downtime restarts, single-box canaries, and faster accepts with no load balancer at all.
  • Run the snippet in two terminals and curl it — two different pids will answer on the same port.