Connection Limits & OS Tuning #
Your WebSocket server handles a thousand clients in staging, and the capacity plan says each node should hold fifty thousand. In the first real load test it stops at 1,024 with EMFILE. You raise the descriptor limit and it stops at 28,000 — this time at the proxy, with Cannot assign requested address. You fix that, and during the first deploy the reconnect storm overflows the accept queue, clients back off for seven seconds, and the dashboard lights up with timeouts. Each wall is a default chosen for workloads with a few thousand short-lived connections, and a real-time server meets them one at a time, in roughly the same order, unless someone goes through them deliberately.
This area covers those limits: operating-system and runtime settings that cap how many long-lived connections a node and its proxies can hold, and how to measure the one limit that should be left — memory. It complements the application-level concerns in Backend WebSocket Connection Management and feeds directly into fleet sizing in Load Testing & Capacity Planning.
Prerequisites #
Tuning limits only helps once the application is not the bottleneck. Before raising anything:
- Know your target: connections per node from a capacity plan, not “as many as possible”. Every limit below is sized from that number.
- Make sure connections are cleaned up. A server leaking sockets in
CLOSE_WAITor keeping half-open peers forever will fill any limit you set; fix that first with Connection Lifecycle & Heartbeats. - Bound per-connection memory growth with backpressure and flow control, so the density you plan for idle connections still holds under traffic.
- Have access to the layer that launches your process — systemd unit, container runtime, node image — because most of these limits are inherited from it, not set by your code.
Core implementation: a startup self-check #
The most useful single piece of code in this area is not a tuning script but a check: the server reads the limits it actually received and refuses to start, or at least warns loudly, if they cannot support its configured target. Limits are set far from the application, by people and tools that change independently, and a silently lowered limit is only discovered at the next traffic peak.
import { readFileSync } from 'node:fs';
const TARGET_CONNECTIONS = Number(process.env.TARGET_CONNECTIONS ?? 50_000);
const FD_HEADROOM = 1.2; // listening socket, upstreams, logs, spikes
const LISTEN_BACKLOG = 65_535;
function readProc(path: string): string {
try { return readFileSync(path, 'utf8'); } catch { return ''; }
}
function sysctl(name: string): number[] {
return readProc(`/proc/sys/${name.replace(/\./g, '/')}`).trim().split(/\s+/).map(Number);
}
export function checkLimits(): { ok: boolean; problems: string[] } {
const problems: string[] = [];
// 1. Descriptors: the soft limit is what accept() is checked against.
const limits = readProc('/proc/self/limits').split('\n').find((l) => l.startsWith('Max open files'));
const softFds = Number(limits?.trim().split(/\s+/)[3]);
if (softFds < TARGET_CONNECTIONS * FD_HEADROOM) {
problems.push(`nofile soft limit ${softFds} < ${Math.ceil(TARGET_CONNECTIONS * FD_HEADROOM)}`);
}
// 2. Accept queue: our listen() backlog is silently capped by somaxconn.
const [somaxconn] = sysctl('net.core.somaxconn');
if (somaxconn < LISTEN_BACKLOG) problems.push(`somaxconn ${somaxconn} caps backlog below ${LISTEN_BACKLOG}`);
// 3. Conntrack, if the module is loaded on this host.
const [ctMax] = sysctl('net.netfilter.nf_conntrack_max');
if (ctMax && ctMax < TARGET_CONNECTIONS * 2) problems.push(`nf_conntrack_max ${ctMax} < 2x target`);
// 4. Kernel TCP memory ceiling (pages) vs a rough idle estimate of 8 KiB per socket.
const [, , tcpMemHigh] = sysctl('net.ipv4.tcp_mem');
const neededPages = Math.ceil((TARGET_CONNECTIONS * 8 * 1024) / 4096);
if (tcpMemHigh && tcpMemHigh < neededPages) problems.push(`tcp_mem high ${tcpMemHigh} pages < ${neededPages}`);
return { ok: problems.length === 0, problems };
}
const result = checkLimits();
if (!result.ok) {
console.error({ problems: result.problems }, 'host limits cannot support TARGET_CONNECTIONS');
if (process.env.STRICT_LIMITS === '1') process.exit(1); // fail the pod, not the incident
}
Run it in strict mode in production images and in warning mode on developer machines. It turns every limit in this area from tribal knowledge into an explicit, testable requirement that travels with the service.
Why defaults fail real-time servers #
Operating system defaults encode an assumption about workload: many short connections, each carrying a request and a response, closed within seconds. Under that assumption a thousand descriptors per process is generous, a 4,096-entry accept queue drains instantly, and a proxy’s ephemeral ports are recycled faster than they are consumed because connections are pooled. A WebSocket server violates the assumption on every axis. Connections live for hours, so they accumulate instead of recycling. They arrive in synchronized bursts after deploys and network events, so queues that are fine on average overflow at the peak. And each one is held one-to-one through every proxy on the path, so resources that HTTP shares across requests are consumed per client.
The second pattern worth internalising is that limits are layered and inherited. The descriptor limit a Node process runs with is the minimum of kernel ceilings, the launcher’s configuration and the process’s own soft limit. Conntrack capacity is set by a sysctl that kube-proxy may overwrite. The proxy’s port range is a property of the proxy host, not of the backend you are debugging. Tuning succeeds when you trace the effective value for the running process, not when you edit the configuration file you happen to know about.
Planning density before tuning #
Every number in the configuration table below is derived from one input: the number of concurrent connections a node is meant to hold. Getting that input right matters more than any individual sysctl, and it comes from a short chain of reasoning rather than from a benchmark.
Start with the peak concurrent connections the whole service must support, including headroom for growth and for a reconnect storm, when for a few seconds old and new connections overlap. Divide by the number of nodes you are willing to run, keeping in mind that more, smaller nodes spread the blast radius of a single failure: a node holding 200,000 connections turns one crash into 200,000 simultaneous reconnects landing on its neighbours. Many teams deliberately cap nodes well below what the hardware could hold for exactly this reason. The result is the target that the self-check above enforces.
Then check the target against memory, the limit that should remain once every configurable limit is raised. Multiply the measured per-connection cost — heap, native and kernel — by the target, add the process baseline and headroom for traffic-driven buffer growth, and compare it with the node’s memory. If it does not fit, the target is wrong, not the tuning. Measuring memory per WebSocket connection gives the per-connection number; capacity planning for WebSocket fleets turns it into a node count.
Only then derive the limits: descriptors at the target plus 20%, the accept backlog large enough for the peak second of a reconnect storm, conntrack at twice the flows if netfilter is in the path, TCP memory from the target multiplied by a typical buffer size, and proxy ephemeral capacity from the target divided across source and destination tuples. Writing these down next to the target, in the same configuration file or infrastructure module, keeps them from drifting apart when someone changes one of them six months later.
Finally, remember the proxy tier. A proxy holds two sockets per client, consumes an ephemeral port per client toward each backend and tracks two conntrack flows. A proxy fleet sized for HTTP request rates is often badly undersized for WebSocket connection counts, and it hits its walls before the application nodes behind it do.
Configuration reference #
| Setting | Where | Default (typical) | Recommended starting point |
|---|---|---|---|
LimitNOFILE / --ulimit nofile |
systemd / Docker | 1024 soft | ≥ 1.2 × target connections |
fs.nr_open |
sysctl | 1048576 | ≥ the largest nofile you set |
net.core.somaxconn |
sysctl | 4096 | 65535 |
server.listen({ backlog }) |
Node | 511 | 65535 (capped by somaxconn) |
net.ipv4.tcp_max_syn_backlog |
sysctl | 1024–4096 | 65535 |
net.netfilter.nf_conntrack_max |
sysctl / kube-proxy | 65536–262144 | ≥ 2 × flows, or NOTRACK the port |
net.ipv4.tcp_mem |
sysctl | ~9% of RAM | sized from connections × buffer |
net.ipv4.ip_local_port_range |
sysctl (proxy) | 32768–60999 | 1024–65535 with reserved ports |
net.ipv4.tcp_tw_reuse |
sysctl (proxy) | 2 (loopback only) | 1 |
perMessageDeflate |
ws option |
off in ws server |
off, or small windows at high density |
Edge cases & gotchas #
Kubernetes overwrites conntrack. kube-proxy sets nf_conntrack_max from --conntrack-max-per-core × CPU count at startup, replacing whatever node provisioning wrote. Configure it through kube-proxy’s configuration, or your sysctl will be silently reverted.
limits.conf does nothing for services. It applies to PAM login sessions only. A server launched by systemd, Docker or a Kubernetes runtime never reads it; set the limit in the launcher.
Proxies to a virtual IP share one port range. If nginx or HAProxy proxies to a single ClusterIP or load-balancer VIP, every upstream WebSocket shares one destination tuple and one ephemeral range, regardless of how many pods sit behind it. Proxy to endpoints directly.
Measuring heap alone underestimates memory. heapUsed excludes zlib compression contexts, Buffers and kernel socket buffers. Containers are killed on total memory, so plan with all of them, as shown in measuring memory per WebSocket connection.
Container memory limits include kernel socket memory. Under cgroup v2, TCP buffer memory for a container’s sockets is charged to that container’s memory cgroup. A pod whose V8 heap is comfortably inside its limit can still be OOM-killed when tens of thousands of sockets each grow their buffers during a traffic burst. Size container limits from the full per-connection measurement, and watch the sock line in the cgroup’s memory.stat alongside heap metrics.
Health checks consume the resources they measure. A liveness probe that opens a real WebSocket every few seconds adds connections, descriptors and conntrack entries on every node, and on a node already at its limit the probe itself fails and the orchestrator restarts a healthy process. Probe a plain HTTP endpoint that reports the limit headroom instead, as described in WebSocket readiness and liveness probes.
Verification #
Verify effective values on running processes and hosts, then prove them under load:
PID=$(pgrep -f "node dist/server.js" | head -1)
grep "Max open files" /proc/$PID/limits # effective descriptor limits
ss -ltn 'sport = :8080' # Send-Q = effective backlog
nstat -az TcpExtListenOverflows TcpExtListenDrops # storm damage since boot
sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max
cat /proc/net/sockstat # TCP sockets and memory pages
# On proxy hosts: upstream connections per destination vs the port range
ss -tn state established '( dport = :8080 )' | awk 'NR>1{print $4}' | sort | uniq -c | sort -rn | head
The load test that matters is not a throughput benchmark but a density and storm test: hold the target connection count for ten minutes, then restart the server and watch the fleet reconnect with jitter. Pass criteria are no EMFILE, no EADDRNOTAVAIL, no increase in ListenOverflows, no conntrack drops in dmesg, and a full reconnect inside the backoff window. The storm itself is modelled in exponential backoff with jitter for WebSocket reconnects.
Guides in this area #
- Raising File Descriptor Limits for WebSockets — every layer that caps
nofile, from kernel ceilings to systemd, Docker and Kubernetes, and how to verify the running process. - Tuning Linux TCP for a Million WebSockets — accept queues, socket buffer memory and conntrack sized with arithmetic, plus the sysctls not worth touching.
- Ephemeral Port Exhaustion on WebSocket Proxies — why proxies stall near 28,000 upstream sockets per backend and how to multiply the ceiling.
- Measuring Memory per WebSocket Connection — a harness that splits heap, native and kernel memory so density plans survive production.
FAQ #
What is the first limit a Node.js WebSocket server hits? #
Almost always the per-process descriptor limit, which defaults to a soft value of 1024 on many distributions and container runtimes. The symptom is EMFILE on accept() at roughly a thousand concurrent connections.
Do I need a special kernel for a million connections? #
No. A stock modern Linux kernel handles millions of TCP connections; what changes is configuration — descriptor limits, backlog, TCP memory and conntrack — plus enough RAM for kernel and application memory per connection.
Should these settings be applied by the application? #
The application should check them, as in the self-check above, but not set them: most require privileges and belong to node provisioning (sysctl files, systemd units, runtime configuration) so they are consistent across the fleet and reviewable.
Is it better to run fewer large nodes or more small ones? #
Fewer large nodes are cheaper per connection, because the fixed per-process overhead is amortised over more clients. More small nodes reduce the blast radius of a crash or a deploy, because each one sheds fewer connections into a reconnect storm at once. Most teams settle on a per-node cap well below what the hardware could hold, chosen so that the neighbours can absorb one node’s reconnects without breaching their own accept queues.
Does raising limits make the server faster? #
No — limits only determine where the server stops accepting work. Raising them lets a node hold more connections at the same per-connection cost; throughput per connection is governed by CPU, message size and the event loop. If a load test shows latency climbing well before any limit is reached, the bottleneck is elsewhere, usually serialization or fan-out.
How do I know which limit I hit? #
Each has a distinct symptom: EMFILE for descriptors, EADDRNOTAVAIL for ephemeral ports on a proxy, ListenOverflows for the accept queue, nf_conntrack: table full in dmesg for conntrack, and TCP: out of memory for tcp_mem. Search for the symptom, then check the matching setting on the right host.
Related #
- Connection Lifecycle & Heartbeats — releasing connections so limits are not filled by the dead.
- Load Balancer Sticky Sessions — the proxy tier these limits also apply to.
- Load Testing & Capacity Planning — proving the limits at target density.
- Horizontal Scaling on Kubernetes — node and pod configuration for dense workloads.