Linux TCP Backlog, somaxconn and TIME_WAIT: Why Connections Get Dropped Under Load
When a server falls behind under load, new connections queue in the kernel — and when that queue overflows, they're silently dropped. How the SYN and accept queues work, net.core.somaxconn and the listen() backlog, spotting overflows with ss and nstat, and why TIME_WAIT and ephemeral port exhaustion bite services that make lots of outbound connections.
A load test shows a cliff: up to some request rate everything is fine, then clients see connection timeouts — not errors, just silence. The application logs show nothing, because the requests never reached it. The kernel dropped them at the door. (Load Testing Your App)
Two queues per listening socket
When a client connects to a listening TCP socket on Linux:
- The client sends SYN. The kernel records a half-open connection in the SYN queue and replies SYN-ACK.
- The client sends ACK. The connection is now established and moves to the accept queue.
- Your application calls
accept()and takes it off the accept queue.
If your app is slow to call accept() — the event loop is blocked, all workers are busy — the accept queue fills. When it's full, the kernel by default drops the final ACK; the client thinks it's connected or keeps retransmitting, and eventually times out.
The backlog and somaxconn
The accept queue's size is the backlog argument your server passes to listen(), capped by net.core.somaxconn:
sysctl net.core.somaxconn
Since Linux 5.4 the default is 4096 (it was 128 for decades). Common server defaults:
- Node.js
server.listen()uses a backlog of 511 unless you pass one. - Nginx uses 511 on Linux unless you set
listen 80 backlog=4096;. - Many frameworks expose a
backlogoption.
Raising somaxconn alone does nothing if the application still asks for 511. Raise both, or at least know which one is binding.
The SYN queue is governed by net.ipv4.tcp_max_syn_backlog, and SYN cookies (net.ipv4.tcp_syncookies = 1, the default) let the kernel keep accepting connections when it overflows, e.g. during a SYN flood. (What Is a DDoS Attack?)
Seeing overflows
For listening sockets, ss shows the current accept queue length and its maximum:
ss -ltn
# State Recv-Q Send-Q Local Address:Port
# LISTEN 0 511 0.0.0.0:443
For a LISTEN socket, Recv-Q is connections waiting to be accepted and Send-Q is the backlog limit. Recv-Q near Send-Q under load = your app isn't accepting fast enough.
Kernel counters tell you whether drops happened:
nstat -az TcpExtListenOverflows TcpExtListenDrops
netstat -s | grep -i -E "overflow|SYNs to LISTEN"
Rising ListenOverflows confirms accept-queue drops.
Fixing it
- Fix the slow accept first. A bigger queue only buys time. Usually the real problem is a blocked event loop or too few workers. (Node.js Event Loop Lag)
- Raise the backlog to absorb bursts:
somaxconnand the application'slisten()backlog. - Scale out with more workers or instances behind a load balancer. (What Is a Load Balancer?)
- Shed load explicitly (429/503 from a proxy) rather than letting the kernel drop silently. (Nginx Rate Limiting)
TIME_WAIT: the other side of the connection
When a TCP connection closes, the side that closed first keeps it in TIME_WAIT for a while (on Linux, 60 seconds) to catch stray late packets and to make sure the final ACK arrived. That's normal and healthy.
It becomes a problem for services that make many short outbound connections to the same destination — an app calling an API or a database without connection reuse. Each outbound connection uses a local ephemeral port, and the range is finite:
sysctl net.ipv4.ip_local_port_range
# 32768 60999 → ~28,000 ports
28,000 ports ÷ 60 seconds ≈ 470 new connections per second to a single destination IP and port before you run out. Then connect() fails with EADDRNOTAVAIL ("Cannot assign requested address").
Count them:
ss -tan state time-wait | wc -l
Fixes, best first
- Reuse connections. HTTP keep-alive and connection pools mean one connection serves thousands of requests. This is almost always the real fix. (Postgres Connection Pooling)
net.ipv4.tcp_tw_reuse = 1lets new outgoing connections reuse TIME_WAIT sockets when it's safe (using timestamps). Reasonable on clients.- Widen
ip_local_port_range. - Don't look for
tcp_tw_recycle— it broke clients behind NAT and was removed from Linux in 4.12.
The summary
- Connections wait in the SYN queue, then the accept queue; when the accept queue overflows, they're dropped silently.
- The accept queue =
min(listen backlog, somaxconn); check withss -ltnandnstat. - TIME_WAIT is normal; port exhaustion means you're not reusing connections.
EasySpawn runs your app behind a managed proxy on its own VM, and Claude Code can read ss and nstat with you when a load test hits a wall. See how it works or join the waitlist.
Related: Container Networking Internals · Graceful Shutdown in Node.js · Ports Explained · HTTP/2 vs HTTP/3
Keep reading
Postgres Replication Slots: Why an Inactive Slot Can Fill Your Disk
Replication slots guarantee a replica or CDC consumer never misses WAL — by keeping it until the consumer confirms it. When the consumer disappears, WAL piles up until the disk is full. How slots work, how to measure retained WAL, max_slot_wal_keep_size and idle timeouts, the xmin side effect, and safe cleanup.
Postgres Logical Replication: Publications, Subscriptions and Near-Zero-Downtime Upgrades
Logical replication streams row-level changes from chosen tables to another Postgres — even a different major version. How publications and subscriptions work, replica identity, the initial copy, what isn't replicated (DDL, sequences), conflicts, monitoring, and a step-by-step major-version upgrade with a short cutover.