Where 14.8 KB per Connection Goes
Our realtime gateway held 500,000 idle-but-live WebSocket connections across a fleet of Node.js servers. The fleet was sized by memory, not CPU: each connection cost 14.8 KB of resident memory, so a 16 GB node topped out around 1 million sockets in theory and 600,000 in practice.
Heap snapshots showed the cost was spread thin: per-socket buffers, an EventEmitter per connection, closure-captured state and a framing object allocated for every inbound message. None of it was a leak, which is exactly why it never showed up in a profiler.
“We were not out of CPU. We were paying a memory tax on every socket just to keep it politely idle.”
TCP Buffers, epoll and File Descriptors
Default Linux TCP buffers are sized for throughput, not for a million mostly-silent sockets. Shrinking the per-socket read and write buffers returned hundreds of megabytes per node, and raising the file descriptor ceiling removed an entire class of EMFILE incidents.
# fewer bytes pinned per idle connectionnet.ipv4.tcp_rmem = 4096 8192 262144net.ipv4.tcp_wmem = 4096 8192 262144 # room for a million socketsfs.file-max = 2097152net.core.somaxconn = 65535net.ipv4.ip_local_port_range = 1024 65535With tuned buffers the same workload fit in roughly 9 KB per socket on Node.js. The remaining gap was userland: the ws library and V8 itself.
Bun Raw Sockets and Zero GC Pressure
Bun's native WebSocket server keeps connection state in native memory and hands your handler a single shared object, instead of allocating an emitter and closures per socket. Moving the gateway over took the per-connection cost from 14.8 KB to 2.1 KB, and removed the garbage-collector pauses that had been inflating our tail latency.
Bun.serve({ port: 8080, fetch(req, server) { return server.upgrade(req) ? undefined : new Response(null, { status: 426 }); }, websocket: { perMessageDeflate: false, message(ws, data) { ws.send(data); }, },});Checklist for Million-Socket Gateways
If you run a realtime gateway that is sized by memory rather than CPU, audit these checks before adding nodes:
Measure Memory Per Connection First
Divide resident memory by live sockets under a soak test. It is the number your fleet size is really built on.
Shrink TCP Buffers For Idle Sockets
Tune
tcp_rmemandtcp_wmemfor mostly-silent connections and keep large buffers only where throughput needs them.Raise File Descriptor And Port Limits
Set
fs.file-max,somaxconnand the ephemeral port range before the load test, not after the incident.Avoid Per-Connection Allocations
Share one handler across sockets. Per-socket emitters and closures are the hidden cost at this scale.
Disable Compression On Chatty Small Frames
perMessageDeflatebuys little on tiny frames and costs memory and CPU on every connection.Soak For Hours, Not Minutes
Latency tails from garbage collection and kernel memory compaction only appear after sustained load.
