Tuning Linux TCP for a million WebSockets #
You raised the descriptor limit, and the node now accepts connections past the old wall. Somewhere between 100,000 and a million idle WebSockets, new problems appear: the kernel logs nf_conntrack: table full, dropping packet, connection bursts after a deploy produce SYN timeouts, dmesg reports TCP: out of memory -- consider tuning tcp_mem, and throughput collapses even though Node’s heap is fine. These limits live in the kernel’s networking stack, and their defaults assume a machine with a few thousand busy connections, not hundreds of thousands of mostly idle ones. This page covers the sysctls that actually matter for WebSocket density, the arithmetic behind each value, and the ones that are commonly changed for no benefit.
Root cause #
Every established TCP connection costs kernel memory outside your process: a socket structure of a couple of kilobytes, plus receive and send buffers that grow when data is in flight. The kernel caps the total with net.ipv4.tcp_mem, measured in pages; when the total passes the pressure threshold, the kernel shrinks buffers, and past the maximum it refuses allocations — the “out of memory” message — even when free RAM is plentiful. With a million sockets, even the minimum 4 KB buffer each adds up to gigabytes.
Two other defaults bite during bursts rather than at steady state. The listen backlog (net.core.somaxconn and your listen() call’s backlog) bounds how many completed handshakes can wait for accept(); during a reconnect storm tens of thousands of clients arrive in a second, the queue overflows, and the kernel drops SYNs, so clients retry after a second, then three, then seven. And if the host runs iptables or nftables with connection tracking — common on Kubernetes nodes — every connection occupies a conntrack entry, and the table’s default size (nf_conntrack_max, often 65,536 or 262,144) is far below a million.
Resolution #
The values below target a node holding up to a million mostly idle WebSockets with small messages. Treat them as a starting point derived from arithmetic, then verify against your own traffic.
# /etc/sysctl.d/91-websocket-tcp.conf
# --- Accept queue: absorb reconnect storms without SYN drops ----------------
net.core.somaxconn = 65535 # cap on any listen() backlog
net.ipv4.tcp_max_syn_backlog = 65535 # half-open handshakes queued
net.core.netdev_max_backlog = 16384 # packets queued per CPU before the stack
# --- Socket buffer memory ---------------------------------------------------
# Per-socket min / default / max bytes. Small defaults keep idle sockets cheap;
# autotuning still grows busy sockets toward the max.
net.ipv4.tcp_rmem = 4096 16384 4194304
net.ipv4.tcp_wmem = 4096 16384 4194304
# Total TCP buffer memory, in 4 KiB pages: low / pressure / high.
# 1M sockets x ~8 KiB typical in-use buffers ~ 8 GiB ~ 2M pages at "pressure".
net.ipv4.tcp_mem = 1572864 2097152 3145728
# --- Connection tracking (only if netfilter conntrack is loaded) ------------
net.netfilter.nf_conntrack_max = 2097152
# ESTABLISHED entries default to 5 days; keep them past your longest idle socket
# but not forever, so dead flows eventually free their slots.
net.netfilter.nf_conntrack_tcp_timeout_established = 86400
# --- Dead-peer hygiene (backs up application heartbeats) --------------------
net.ipv4.tcp_keepalive_time = 300
net.ipv4.tcp_keepalive_intvl = 30
net.ipv4.tcp_keepalive_probes = 5
Your server must also ask for a large backlog; somaxconn only caps it. Node’s default backlog is 511. Pass a larger value when listening:
import http from 'node:http';
const LISTEN_BACKLOG = 65_535; // capped by net.core.somaxconn
const server = http.createServer();
server.listen({ port: 8080, backlog: LISTEN_BACKLOG });
Conntrack deserves a decision rather than a bigger number. If the node does not need stateful firewalling for WebSocket traffic, exempt the port from tracking entirely with a NOTRACK rule in the raw table, which removes the table from the path. On Kubernetes, kube-proxy in iptables mode relies on conntrack for Service NAT, so raising the maximum is the practical option; kube-proxy also sets nf_conntrack_max itself from its --conntrack-max-per-core flag, which will overwrite a sysctl you set by hand.
What not to change #
Several sysctls appear in every “tune Linux for high concurrency” list and do nothing useful for a WebSocket server. tcp_tw_reuse and the removed tcp_tw_recycle concern outbound connections stuck in TIME_WAIT; a server that accepts connections rarely accumulates TIME_WAIT for its own port, and tcp_tw_recycle actively broke clients behind NAT before it was removed. tcp_fin_timeout controls FIN_WAIT_2, not CLOSE_WAIT, so it will not fix the leak covered in fixing WebSocket CLOSE_WAIT accumulation. And ip_local_port_range matters on proxies making outbound connections, which is covered in ephemeral port exhaustion on WebSocket proxies, not on the server terminating them.
Keepalive settings are a backstop, not a heartbeat. Application-level pings, as in detecting half-open WebSocket connections, detect dead peers in seconds; kernel keepalive only cleans up sockets the application has forgotten.
Verification #
Look for the specific symptoms each setting prevents, before and after the change:
# Accept queue overflows and SYN drops since boot (should stop increasing under load).
nstat -az TcpExtListenOverflows TcpExtListenDrops TcpExtTCPReqQFullDrop
# Current backlog vs limit on the listening socket: Recv-Q is queued, Send-Q is the limit.
ss -ltn 'sport = :8080'
# TCP memory in pages: "mem" vs the tcp_mem thresholds.
cat /proc/net/sockstat
# Conntrack usage vs max, and drops logged by the kernel.
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
dmesg -T | grep -E 'nf_conntrack: table full|TCP: out of memory'
Then run a storm test: hold your target idle connection count, drop them all at once (restart the server), and let clients reconnect with jitter. ListenOverflows should not move, and the fleet should be fully reconnected within your backoff window.
Operational checklist #
FAQ #
How many WebSocket connections can one Linux server handle? #
Millions of idle connections are achievable on a large machine; the limit is memory — kernel socket buffers plus your application’s per-connection state — and the CPU cost of whatever traffic those connections carry. The kernel defaults, not the hardware, are what stop most servers far earlier.
Do I need to change ip_local_port_range on the server? #
No. A server accepting connections uses one local port (its listening port) for all of them; connections are distinguished by the client’s address and port. Ephemeral ports matter on machines making outbound connections, such as proxies.
Why do I see “TCP: out of memory” when the machine has free RAM? #
The kernel limits TCP buffer memory separately through tcp_mem, which defaults to a fraction of RAM calculated at boot. Past its maximum, allocations fail regardless of free memory. Raise it in proportion to your connection count.
Should I enable TCP_NODELAY? #
Yes, for interactive WebSocket traffic — and ws already sets it on accepted sockets. Nagle’s algorithm delays small writes to coalesce them, which adds latency to exactly the small, frequent frames real-time apps send.
Related #
- Raising File Descriptor Limits for WebSockets — the first wall.
- Measuring Memory per WebSocket Connection — the user-space half of the budget.
- Capacity Planning for WebSocket Fleets — turning per-node limits into fleet size.
- Exponential Backoff with Jitter for WebSocket Reconnects — keeping storms inside the backlog.
Back to Connection Limits & OS Tuning.