🍱 Lunchbox Hands

networking

"Address Already in Use": Ephemeral Port Ranges, TIME_WAIT, and Picking a Dev Port

EADDRINUSE has three different causes and most developers only know one: another process, a TIME_WAIT ghost from the server you just killed, or macOS AirPlay squatting on port 5000. Plus the ephemeral ranges Linux, Windows, and macOS actually use (none agree with IANA), demonstrated with real lsof, sysctl, and socket output.

You start the dev server and get Error: listen EADDRINUSE: address already in use :::3000. You run lsof -i :3000, find nothing, try again, same error. Or you Ctrl-C a server, restart it two seconds later, and the port you just released is somehow taken. Or Flask refuses to start on a fresh Mac that has never run anything on port 5000.

“Address already in use” is one error message covering three unrelated situations, and the fix for each is different: another process really is listening; nothing is listening but the kernel is holding the port in TIME_WAIT; or the port is not “in use” at all and you are looking at a permissions error (or a macOS system service) that lands on the same number. Below is the mechanism behind each, with primary sources and real terminal output — including the port ranges your OS hands out for outgoing connections, which are not the ranges IANA says they should be.

The three ranges, and who actually respects them

RFC 6335 §6 splits the 16-bit port space into three ranges:

  • the System Ports, also known as the Well Known Ports, from 0-1023 (assigned by IANA)
  • the User Ports, also known as the Registered Ports, from 1024-49151 (assigned by IANA)
  • the Dynamic Ports, also known as the Private or Ephemeral Ports, from 49152-65535 (never assigned)

The dynamic range is where the kernel picks a local port for outgoing connections — every fetch(), every psql session, every git push grabs one. That is the recommendation. Here is what operating systems actually do:

OSEphemeral rangeWhere it’s setSource
IANA recommendation49152–65535—RFC 6335 §6
Linux32768–60999net.ipv4.ip_local_port_rangeip-sysctl.rst
Windows Vista / Server 2008 and later49152–65535netsh int ipv4 show dynamicport tcpMicrosoft KB 929851
Windows XP / Server 20031025–5000samesame
macOS49152–65535sysctl net.inet.ip.portrangeexecuted below
FreeBSD10000–65535net.inet.ip.portrange.first/lastsys/netinet/in.h

The Linux kernel docs define ip_local_port_range as “the local port range that is used by TCP and UDP to choose the local port,” default 32768 and 60999 — so Linux hands out ephemeral ports from deep inside IANA’s registered range. Windows moved to the IANA range in Vista “to comply with Internet Assigned Numbers Authority (IANA) recommendations,” having used 1025–5000 before. FreeBSD’s header defines IPPORT_EPHEMERALFIRST 10000 for the default policy and reserves 49152–65535 (IPPORT_HIFIRSTAUTO) for the explicit IP_PORTRANGE_HIGH option. And here is macOS 26.2, on this machine:

$ sysctl net.inet.ip.portrange
net.inet.ip.portrange.lowfirst: 1023
net.inet.ip.portrange.lowlast: 600
net.inet.ip.portrange.first: 49152
net.inet.ip.portrange.last: 65535
net.inet.ip.portrange.hifirst: 49152
net.inet.ip.portrange.hilast: 65535
net.inet.ip.portrange.ipport_allow_udp_port_exhaustion: 0

Why this matters for EADDRINUSE: if your dev server is pinned to a port inside your OS’s ephemeral range, some outgoing connection from any process can be assigned that exact port between one run and the next, and your listen() collides with a client socket you will never find by looking for listeners. On Linux that is anything in 32768–60999; on macOS and Windows anything above 49151. The zone no mainstream OS uses for ephemeral allocation is 1024–9999, which is, not coincidentally, where every framework’s default dev port lives.

One more subtlety: “registered” has nothing to do with what is running on your machine. IANA lists port 3000 as hbci/remoteware-cl, 5000 as commplex-main, 8000 as irdmi, and 5173 (Vite) as unassigned. Nobody on your laptop is running HBCI. The registry tells you who claimed a number, not who holds it.

Cause 1: something is actually listening

The boring case, and the one everybody checks first. Ask the OS who owns the port:

OSCommand
macOS / Linuxlsof -nP -iTCP:3000 -sTCP:LISTEN (or lsof -i :3000 for any state)
Linuxss -ltnp — -l listening, -t TCP, -n numeric, -p “show processes using sockets”
Windowsnetstat -ano | findstr :3000 — -o “includes the process ID (PID) for each connection”

The real listener list on the Mac this post was written on, trimmed:

$ lsof -iTCP -sTCP:LISTEN -n -P | head
COMMAND     PID      USER   FD   TYPE  NODE NAME
rapportd    627 kylehurst   10u  IPv4   TCP *:52642 (LISTEN)
ControlCe   634 kylehurst    9u  IPv4   TCP *:7000 (LISTEN)
ControlCe   634 kylehurst   11u  IPv4   TCP *:5000 (LISTEN)
sharingd    701 kylehurst   20u  IPv6   TCP *:8770 (LISTEN)
postgres    885 kylehurst    8u  IPv4   TCP 127.0.0.1:5432 (LISTEN)
node       1595 kylehurst   13u  IPv6   TCP *:3100 (LISTEN)

Two things to notice. rapportd (an Apple continuity daemon) is listening on 52642 — inside the ephemeral range — because it asked for port 0 and let the kernel pick, which is exactly what throwaway servers should do too. And ControlCe on 5000 and 7000 is the subject of Cause 3. The -n -P flags matter: without them lsof resolves names, which is slow and turns 5432 into postgresql so your grep misses it.

Cause 2: nothing is listening — it’s TIME_WAIT

This is the one that produces the “but I just killed it” confusion. When a TCP connection closes, the side that sent the first FIN does not go straight to CLOSED. RFC 9293 — the current TCP specification, obsoleting RFC 793 — defines the state in §3.3.2:

TIME-WAIT - represents waiting for enough time to pass to be sure the remote TCP peer received the acknowledgment of its connection termination request and to avoid new connections being impacted by delayed segments from previous connections.

and mandates its duration in §3.6.1: “When a connection is closed actively, it MUST linger in the TIME-WAIT state for a time 2xMSL (Maximum Segment Lifetime) (MUST-13),” with §3.4.2 taking the MSL “to be 2 minutes.” Real stacks run shorter timers, but the mechanism is the same: the socket pair is kept on the books after close so a late packet from the old connection cannot be mistaken for the new one.

The trap: a server that is Ctrl-C’d with open connections is the active closer of every one of them, so the server side owns the TIME_WAIT entries. The classic BSD rule for bind() says a port with any socket on it — TIME_WAIT ghosts included — is in use. Here it is happening, in Python, because Python’s raw socket module sets no reuse flags for you:

$ python3 timewait.py
tcp4  0  0  127.0.0.1.45123  127.0.0.1.60008  TIME_WAIT
rebind without SO_REUSEADDR: errno 48 EADDRINUSE - Address already in use
rebind with SO_REUSEADDR: OK

The script binds 127.0.0.1:45123, accepts one connection, closes it server-side first, then binds the same port again. No process is listening — netstat shows only the TIME_WAIT pair — and bind() fails anyway. Set one option and it succeeds. That option is SO_REUSEADDR, from Linux socket(7):

Indicates that the rules used in validating addresses supplied in a bind(2) call should allow reuse of local addresses. For AF_INET sockets this means that a socket may bind, except when there is an active listening socket bound to the address.

The macOS setsockopt(2) man page uses the same first sentence. Note the wording: it relaxes the rules for TIME_WAIT and overlapping addresses, but it does not let you bind on top of an active listener at the same address — it reclaims a port from a dead server, it cannot steal one from a live one.

Why Node users rarely see this — and Python users do

If you write servers in Node you may never have hit the TIME_WAIT variant, for a specific reason. The net docs state: “All net.Socket are set to SO_REUSEADDR.” libuv does it unconditionally in uv__tcp_bind:

on = 1;
if (setsockopt(tcp->io_watcher.fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)))
  return UV__ERR(errno);

Same experiment in Node 22.18.0, same machine — the immediate rebind just works:

$ node rebind.js
node rebind on 45124: OK (libuv sets SO_REUSEADDR)
$ netstat -an -p tcp | grep '\.45124 '
tcp4  0  0  127.0.0.1.45124  127.0.0.1.60010  TIME_WAIT

So when Node throws EADDRINUSE, it is essentially always Cause 1 — which is why the Node docs describe the error as happening “when another server is already listening on the requested port/path/handle.” Two Node knobs get confused with SO_REUSEADDR and are not it: exclusive (default false) only controls whether cluster workers share one listening handle, and reusePort (added in v22.12.0) maps to SO_REUSEPORT, which per socket(7) “permits multiple AF_INET or AF_INET6 sockets to be bound to an identical socket address” — genuine multi-process load balancing, Linux 3.9+ and FreeBSD 12+ only; “on unsupported platforms, this option raises an error.”

Cause 3: it isn’t “in use” at all

EACCES, the privileged-port error that reads like EADDRINUSE

On Linux, ip(7) is explicit: “The port numbers below 1024 are called privileged ports… Only a privileged process (on Linux: a process that has the CAP_NET_BIND_SERVICE capability…) may bind(2) to these sockets.” The failure is EACCES — “binding to a privileged port without superuser privileges” in the same man page — not EADDRINUSE, but both surface as “the server won’t start on port 80.” The boundary is ip_unprivileged_port_start, default 1024.

macOS is different, and it surprises people arriving from Linux. Since Mojave, Apple no longer enforces the restriction for wildcard binds. Quinn “The Eskimo!” of Apple Developer Technical Support, on the developer forums: “iOS never had this restriction and we’ve now lifted it on macOS… this was an intentional change and we don’t plan to change it back.” The same thread notes that binding a privileged port to a specific address (not 0.0.0.0) still returns EACCES. Verified here as a non-root user on macOS 26.2 — Node bound port 80 on the wildcard address without complaint:

$ node eaddr.js
server B: EADDRINUSE - listen EADDRINUSE: address already in use :::3100
C listening on 80?!

(Port 3100 was genuinely held by another Node process — Cause 1 — and port 80 was not privileged at all.)

Windows has its own pair: WSAEADDRINUSE is 10048, while 10013 is WSAEACCES, “Permission denied,” whose documented causes include another process holding the address “with exclusive access” via SO_EXCLUSIVEADDRUSE. Flask’s docs list both [Errno 98] Address already in use (Linux’s EADDRINUSE) and [WinError 10013] under one “Address already in use” heading — accurate advice, and a perfect example of the two errors being lumped together. macOS reports EADDRINUSE as errno 48 and EACCES as 13.

macOS AirPlay Receiver: the port 5000 and 7000 squatter

Since macOS Monterey, the AirPlay Receiver feature makes Control Center listen on TCP 5000 and 7000 — the ControlCe rows in the lsof output above. Flask’s development server docs now carry the warning: “macOS Monterey and later automatically starts a service that uses port 5000. You can choose to disable this service instead of using a different port by searching for ‘AirPlay Receiver’ in System Settings and toggling it off.” The Apple developer forum thread that identified it has the accepted answer: “Control Center stops listening to those ports when I turn off ‘AirPlay Receiver’ in the ‘Sharing’ System Preference.”

A detail that explains why some tools fail on 5000 and others do not: Control Center binds the wildcard address (*:5000), and per the SO_REUSEADDR rule above, a reuse-enabled socket may bind a different specific address on the same port. Measured on this machine:

reuse=False bind 0.0.0.0:5000   -> errno 48 EADDRINUSE
reuse=False bind 127.0.0.1:5000 -> errno 48 EADDRINUSE
reuse=True  bind 0.0.0.0:5000   -> errno 48 EADDRINUSE
reuse=True  bind 127.0.0.1:5000 OK

Node (reuse always on) binding 127.0.0.1:5000 succeeds; 0.0.0.0:5000 fails. Not something to rely on — you’d have two services answering on 5000 depending on the interface — but it is why the error is intermittent across frameworks. Toggle AirPlay Receiver off or move to 5001.

The ports that collide most often

Every default below was checked against the framework’s own documentation.

PortDefault forSourceNote
3000Rails (bin/rails server), Next.js (next dev/next start), Create React AppRails guide, Next CLI, CRACRA prompts to try the next free port
5000Flask (flask run)Flask CLImacOS AirPlay Receiver
5173Vite dev server (server.port)Vite configbumps automatically; strictPort: true to fail instead
8000Django (runserver “runs on port 8000 on the IP address 127.0.0.1”)django-admin
8080http-alt — proxies, docker run -p 8080:80IANA
3306MySQL (--port default 3306)MySQL 8.4
5432PostgreSQL (port: “5432 by default”)PostgreSQLa Docker Postgres fights a local one
6379Redis (port 6379 in redis.conf)redis.conf
27017MongoDB (mongod/mongos)MongoDB
9229Node inspector (--inspect, “Default is 127.0.0.1:9229”)Node CLItwo --inspect processes = EADDRINUSE on 9229, not your app port

The Docker angle in one sentence: -p 8080:80 is host:container, and the host side is the one that must be free — our docker run to docker-compose guide covers the mapping, and getting the order backwards is a common way to publish onto a port that is already occupied.

A decision procedure

  1. lsof -nP -iTCP:PORT -sTCP:LISTEN (or ss -ltnp / netstat -ano). A process shows up → Cause 1. Kill it or change the port.
  2. Nothing listening, but netstat -an | grep PORT shows TIME_WAIT rows → Cause 2. Set SO_REUSEADDR (Python: sock.setsockopt(SOL_SOCKET, SO_REUSEADDR, 1)). Node already does.
  3. Errno is 13 / EACCES / WinError 10013, not 48/98 / EADDRINUSE / 10048 → Cause 3. Privileged port on Linux (use ≥ 1024, CAP_NET_BIND_SERVICE, or a reverse proxy), or Windows exclusive-use.
  4. On a Mac, and the port is 5000 or 7000 → AirPlay Receiver. Search System Settings for it and toggle it off.
  5. The port is above 32767 on Linux or above 49151 on macOS/Windows, and the failure is intermittent → you are inside the ephemeral range. Move below 10000, or bind port 0 and let the kernel choose.

How our tools fit in — and what they do not do

The random port generator is deliberately simple, and since this post is about where ports come from, here is exactly how it picks them. It draws uniformly from 1–65535 using Math.random(); with the default “Avoid well-known (<1024)” box ticked, the lower bound becomes 1024. That is the entire algorithm — there is no exclusion list of registered ports (it can return 5432), and it has no idea what is listening on your machine, because a browser tab cannot ask your kernel. Two honest consequences: about one draw in four lands in the 49152–65535 range macOS and Windows use for outgoing connections, and about 44% land inside Linux’s 32768–60999. The UI exposes no custom min/max, so if you want a port outside every OS’s ephemeral zone, regenerate until you get one in 1024–9999 — or, for a throwaway server, skip the tool and bind port 0.

The well-known port reference is a hand-curated table of 42 entries, from 20 (FTP-DATA) to 27017 (MongoDB), searchable by number, service, protocol, or description. It covers the classic system ports (SSH, DNS, HTTP/S, SMTP, LDAP, SMB) and the infrastructure ports from the table above (3306, 5432, 6379, 27017, 9200, 11211, 8080, 8443). It is not a mirror of the IANA registry — it is a static list in our source, so it will not tell you 5000 is commplex-main, and it has no rows for framework dev ports (3000, 5000, 5173, 8000, 9229) because IANA does not attribute those to Rails or Vite. For “what is this number in a firewall log,” it is the fast answer; for “who registered port 4242,” go to IANA.

If the confusion is about addresses rather than ports — what 0.0.0.0 versus 127.0.0.1 actually binds, or why a container reaches a port your laptop cannot — the subnet calculator and our CIDR and subnetting explainer cover the address side, and what is my IP shows what the outside world sees. And if the connection that keeps dying is a WebSocket rather than a listen() call, the failure modes are different enough to have their own post: why your WebSocket keeps disconnecting.