Backend WebSocket Connection Management #
A WebSocket connection is a long-lived, stateful TCP socket — and that single fact reshapes every backend assumption you carry over from stateless HTTP. Connections live for minutes or hours, pin themselves to one process, hold a file descriptor and kernel buffers the whole time, and fail silently when a mobile network drops a packet. This area is the operational reference for engineers running WebSocket servers in production: how a socket is born, kept alive, authenticated, routed across nodes, observed, and torn down without leaking resources or dropping messages. If you are debugging zombie sockets, sizing a fleet for a million concurrent peers, or chasing why connections die behind a load balancer, start here.
Infrastructure baseline #
Before any application code runs, the kernel and the proxy in front of it must be configured to keep long-lived sockets alive. The two most common production incidents — connections dropping at exactly 60 seconds, and EMFILE: too many open files under load — are both infrastructure misconfigurations, not bugs in your handler.
The reverse proxy must forward the Upgrade/Connection headers and set read/send timeouts that comfortably exceed your heartbeat interval. If proxy_read_timeout is shorter than the gap between frames, the proxy silently closes idle-but-healthy connections.
# nginx.conf — WebSocket proxy headers and timeout alignment
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws {
proxy_pass http://ws_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 90s; # must exceed HEARTBEAT_INTERVAL_MS
proxy_send_timeout 90s;
}
Each open socket consumes a file descriptor. The default soft limit of 1024 caps a single process at roughly a thousand connections — far below what one Node.js process can actually serve. Raise the descriptor limit and tune TCP keepalive so the kernel itself reaps half-open connections that the application layer never hears about.
# Container / host runtime tuning (apply before the process starts)
ulimit -n 1048576 # per-process FD ceiling
sysctl -w net.ipv4.tcp_keepalive_time=300 # idle seconds before keepalive probe
sysctl -w net.ipv4.tcp_keepalive_intvl=60 # interval between probes
sysctl -w net.core.somaxconn=4096 # accept-queue depth for connection spikes
Terminate TLS at the edge (wss://) so the application process handles plaintext frames — terminating TLS in-process burns CPU you would rather spend on message dispatch. Detailed TLS hardening and cipher selection live with Security & TLS Configuration.
Core mechanism: the connection registry #
Every multi-connection WebSocket server is built around one data structure: a registry mapping a stable client identity to its live socket plus liveness metadata. Sends, broadcasts, and teardown all index into it. Without a heartbeat sweep over that registry you accumulate zombie sockets — TCP connections the OS believes are open but whose peer vanished (laptop lid closed, phone lost signal). They never fire a close event, so they leak descriptors until the process exhausts its limit.
The registry below pairs an application-level ping/pong heartbeat with an idle sweep. The heartbeat doubles as your dead-peer detector and your round-trip latency probe; the deeper mechanics are covered in Connection Lifecycle & Heartbeats.
// Production connection registry with heartbeat sweep and error boundaries
import { WebSocketServer, WebSocket } from 'ws';
import { randomUUID } from 'node:crypto';
interface Conn { ws: WebSocket; lastSeen: number; alive: boolean }
const registry = new Map<string, Conn>();
const HEARTBEAT_INTERVAL_MS = 30_000; // how often we ping live sockets
const IDLE_TIMEOUT_MS = 75_000; // declare dead after ~2.5 missed pings
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', (ws: WebSocket, req) => {
const clientId = (req.headers['x-client-id'] as string) ?? randomUUID();
registry.set(clientId, { ws, lastSeen: Date.now(), alive: true });
// pong is the peer's reply to our ping — proof the socket is two-way alive
ws.on('pong', () => {
const entry = registry.get(clientId);
if (entry) { entry.alive = true; entry.lastSeen = Date.now(); }
});
ws.on('error', (err: Error) => {
console.error(`[WS_ERROR] ${clientId}: ${err.message}`);
cleanup(clientId); // errors don't always precede a close
});
ws.on('close', () => cleanup(clientId));
});
function cleanup(id: string): void {
const entry = registry.get(id);
if (!entry) return;
entry.ws.removeAllListeners(); // break listener references for the GC
entry.ws.terminate(); // hard-close; do not wait for the FIN handshake
registry.delete(id);
}
// Single sweep drives both liveness probing and zombie reaping
const sweep = setInterval(() => {
const now = Date.now();
for (const [id, entry] of registry) {
if (!entry.alive || now - entry.lastSeen > IDLE_TIMEOUT_MS) {
console.warn(`[ZOMBIE] terminating ${id}`);
cleanup(id); // missed the previous ping → presumed dead
continue;
}
entry.alive = false; // cleared here, re-set only by a pong
entry.ws.ping();
}
}, HEARTBEAT_INTERVAL_MS);
process.on('SIGTERM', () => {
clearInterval(sweep);
for (const id of registry.keys()) cleanup(id);
process.exit(0);
});
The alive flag flips false on every sweep and is set true only by an incoming pong. A socket that misses one full interval is terminated on the next pass — bounded, predictable, and free of the unbounded timers that an IDLE_TIMEOUT_MS-only approach leaves dangling.
Scaling & architecture #
A single Node.js process comfortably holds tens of thousands of connections, but one process is a single point of failure and a hard ceiling. Horizontal scaling breaks the convenient assumption that the sender and the recipient share memory: client A is pinned to node 1, client B to node 3, and a direct entry.ws.send() only reaches sockets in the local registry. Cross-node delivery requires a message broker. Each node subscribes to the broker and publishes outbound payloads to it; the broker fans them out so the node that actually owns the target socket performs the final send. Broker selection, fan-out topology, and delivery guarantees are the subject of Scaling Real-Time Infrastructure.
Two routing strategies trade CPU against bandwidth. Broadcast publishes every message to every node, which then filters by local ownership — simple, but every node pays to inspect every message. Consistent hashing (or directory lookup) routes a message only to the node that owns the target, which scales far better for directed messages but needs an ownership map. Pin each client to one node with Load Balancer Sticky Sessions so a connection survives rolling deploys, and partition channels with Server-Side Routing Patterns for multi-tenant isolation and per-channel rate limiting.
// Cross-node delivery: subscribe to the broker, deliver to the owning socket
import { Redis } from 'ioredis';
const sub = new Redis(process.env.REDIS_URL!);
const FANOUT_CHANNEL = 'ws:fanout';
await sub.subscribe(FANOUT_CHANNEL);
sub.on('message', (_channel, raw) => {
const { targetId, payload } = JSON.parse(raw) as { targetId: string; payload: string };
const entry = registry.get(targetId); // is this socket local to me?
if (entry?.ws.readyState === WebSocket.OPEN) {
entry.ws.send(payload); // only the owning node sends
}
// no local match → another node owns it; safely ignore
});
During rolling deploys, drain rather than kill: signal clients to reconnect, close with code 1001, and wait for the registry to empty before exiting so the load balancer can shift them to healthy nodes.
// Graceful drain on deploy — give clients a reconnect hint, then let them go
async function drain(): Promise<void> {
for (const { ws } of registry.values()) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'SERVER_SHUTDOWN', reconnectInMs: 2_000 }));
ws.close(1001, 'server restarting'); // 1001 = going away
}
}
await new Promise<void>((resolve) => {
const t = setInterval(() => { if (registry.size === 0) { clearInterval(t); resolve(); } }, 500);
});
}
Clients should rejoin with exponential backoff plus jitter so a fleet restart does not trigger a thundering-herd reconnect; that client-side logic lives in Auto-Reconnection Strategies.
Observability checklist #
You cannot operate what you cannot see, and WebSocket failures are quiet — a stalled fan-out or a slow client backing up its send buffer produces no error log, only degraded latency. Instrument these named signals from day one:
Wire these to a collector and dashboard them per node — the standardized exporters and span conventions are detailed in WebSocket Observability & Monitoring.
Failure modes #
| Failure | Symptom | Root cause | Mitigation |
|---|---|---|---|
| Zombie sockets | Active-connection gauge climbs, never falls; descriptors leak | Peer vanished without a TCP FIN; no close event fires |
Application ping/pong sweep terminates sockets that miss a heartbeat |
| 60-second drops | Connections die on a fixed interval when idle | Proxy proxy_read_timeout shorter than heartbeat gap |
Set proxy timeout above HEARTBEAT_INTERVAL_MS; keep frames flowing |
EMFILE under load |
New connections refused once a node fills | File-descriptor soft limit (1024) far below real capacity | Raise ulimit -n; alert on ws_connections_active nearing ceiling |
| Cross-node black hole | Messages reach some clients, silently not others | Node sends only to its local registry; no broker fan-out | Publish to a pub/sub broker; deliver from the owning node |
| Slow-consumer OOM | Heap grows, then the process crashes under broadcast | bufferedAmount accumulates faster than a slow client drains |
Cap ws_send_buffer_bytes; disconnect over threshold |
Capacity planning per node #
“How many connections can one node hold?” is the question every real-time team asks second, right after “why did it drop?”. The answer is arithmetic rather than folklore, and it decomposes into three independent limits that bind in a predictable order.
File descriptors bind first. Every socket is a descriptor, and the default soft limit of 1024 is the ceiling almost every team meets before any other. Raise it deliberately — ulimit -n 65536 in the unit file, or nofile in the container spec — and treat the number as a capacity decision rather than a formality. A process that hits the descriptor limit does not degrade; it refuses every new upgrade with EMFILE while continuing to serve existing connections perfectly, which makes the symptom look like a load-balancer problem rather than a server one.
Memory binds second. A ws connection costs roughly 30–40 KB of heap once you include the socket object, its receiver and sender state, and the default buffers — before any application state. Add your own per-connection data: a session object, a subscription set, a rate-limit bucket. Multiply by the target connection count and compare against the pod limit with at least 30% headroom for garbage-collection headroom and burst traffic.
The event loop binds third, and it is the one that produces the confusing incidents. CPU utilisation can sit at 40% while p99 message latency climbs into seconds, because the loop is saturated by many small tasks rather than by raw computation. Watch monitorEventLoopDelay from perf_hooks alongside CPU: a mean loop delay above a few milliseconds means you are past the useful capacity of the process regardless of what the CPU graph says.
The chart below computes the memory limit for a range of connection counts at a realistic per-socket cost.
Three practices turn that arithmetic into an operable system. First, configure an explicit connection cap below the memory crossing point and reject beyond it with 1013 Try Again Later, so a node degrades by refusing rather than by dying. Second, enforce the cap at the node, not only at the load balancer — a balancer’s view of health lags reality by a health-check interval, which is long enough to accept thousands of connections a saturated node cannot serve. Third, size for the reconnect surge rather than the steady state: a fleet running at 90% of capacity has no room to absorb the clients of a node that just died, and the resulting cascade is far more damaging than the original failure. Sixty to seventy per cent steady-state utilisation is the range that survives losing a node.
Finally, remember that per-connection cost is a variable you control. Compression adds roughly 100–300 KB per socket depending on window settings; a per-connection message history array can add unbounded amounts; and a large per-connection closure captured by a listener can quietly double your footprint. Measure the real number with a heap snapshot at a known connection count rather than trusting an estimate — the difference between 34 KB and 90 KB per socket is the difference between one pod and three.
Flow control and the slow-consumer problem #
Every server in this area shares one blind spot: socket.send() does not fail when the client cannot keep up. TCP applies backpressure to the kernel, the kernel applies it to Node’s stream, and Node does the only thing it can — it queues your data in userspace and keeps accepting more. That queue is unbounded, and it is measured by bufferedAmount.
The consequence is that a handful of slow clients can consume more memory than your entire healthy fleet. A phone on a congested cell, a laptop suspended with the tab open, a NAT mapping that expired without a FIN — each one keeps accepting your writes into a queue that never drains. Nothing in the API tells you; the connection looks open, the gauge counts it as healthy, and RSS climbs by tens of megabytes an hour on whichever node happened to receive them.
The remedy is a policy, applied to every outbound write, that reads the queue before deciding what to do with the message. Above a high-water mark, non-critical traffic is either coalesced by key — keeping only the newest value per entity — or dropped and counted. Below a low-water mark, normal sending resumes, with the gap between the two marks providing the hysteresis that stops a socket flapping between paused and resumed on every drained packet. Above a kill mark, the connection is closed with 1013, because reconnecting and resynchronising is genuinely cheaper for both sides than catching up.
That last decision needs one refinement. close() starts a closing handshake and waits for the peer to reply, which a black-holed connection never will — so a socket whose queue has not moved across two heartbeat sweeps needs terminate() instead, which destroys the descriptor immediately and reclaims the memory now. Distinguishing “slow” from “gone” is exactly what the heartbeat already tells you, which is why the two mechanisms belong in the same sweep.
The policy also requires a decision you cannot make in code: which message types may be dropped. Anything representing current state — a price, a cursor, a presence flag — should be coalesced, because delivering a stale value later is worse than never delivering it. Anything representing an event with independent meaning — an order, a chat line, an acknowledgement — must not be dropped, and needs a durable path with retry instead. Classify every message type before writing the policy; a system that has not done that classification will eventually drop the one message class it could not afford to.
The full implementation, including the token bucket that bounds inbound traffic and the diagnostic procedure for a heap already held by socket queues, is in WebSocket backpressure and flow control.
Explore this area #
- Connection Lifecycle & Heartbeats — the handshake, ping/pong timing, idle timeouts, and detecting dead peers before they leak resources.
- Auto-Reconnection Strategies — client-side backoff with jitter, state reconciliation on resume, and surviving network handoffs.
- Load Balancer Sticky Sessions — pinning a connection to one node across ALB, HAProxy, and Kubernetes so deploys do not sever it.
- Server-Side Routing Patterns — channel namespacing, multi-tenant isolation, and per-channel rate limiting on the server.
- WebSocket Authentication & Authorization — validating JWTs on the upgrade and enforcing origin and CSRF checks on the handshake.
- WebSocket Observability & Monitoring — instrumenting with OpenTelemetry and exporting connection metrics to Prometheus.
- WebSocket Backpressure & Flow Control — watermarks, coalescing, per-client rate limits, and the slow-consumer memory leak they prevent.
FAQ #
Why do my WebSocket connections drop after 60 seconds? #
Almost always the reverse proxy, not your code. nginx defaults proxy_read_timeout to 60 seconds and closes any connection idle longer than that — including healthy ones between messages. Set the proxy timeout above your heartbeat interval and send application-level pings so the socket is never idle past the limit.
How many WebSocket connections can one Node.js process handle? #
Tens of thousands per process once the file-descriptor limit is raised — the default soft limit of 1024 is the first ceiling you hit, not memory or CPU. Each idle connection costs a descriptor plus a few kilobytes of kernel and heap buffers, so the practical bound is roughly memory divided by per-connection state. Raise ulimit -n, watch ws_connections_active, and scale out to more nodes well before a single process saturates.
Do I need sticky sessions for WebSockets? #
For the raw protocol, only the HTTP upgrade must land on a node that accepts it; after 101 Switching Protocols the TCP connection stays pinned to that process for its lifetime regardless. Sticky sessions matter when a reconnect or a fallback transport must return to the same node to recover session state, and they make rolling deploys predictable. See Load Balancer Sticky Sessions for ALB and HAProxy specifics.
How do I broadcast a message to clients connected to different servers? #
A local send() only reaches sockets in that process’s registry. Put a pub/sub broker (Redis, NATS, or Kafka) between nodes: every node subscribes, publishes outbound messages to the broker, and the node that owns the target socket performs the final delivery. The fan-out and delivery-guarantee patterns are covered in Scaling Real-Time Infrastructure.
What is the first thing to check when memory grows but connections do not? #
The send queues. Take a heap snapshot and sort by retained size: if the top entries are Buffer objects retained by a Sender retained by a WebSocket, you have slow consumers rather than a leak, and the fix is a watermark policy rather than a cleanup bug. The two look identical on a memory graph and need completely different responses, which is why fixing slow consumer memory growth starts with the snapshot rather than the code.
What changes for Socket.IO versus raw ws? #
Socket.IO layers reconnection, rooms, acknowledgements, and a multi-node adapter on top of the protocol, so several mechanisms here come built in — but it also adds a custom framing and handshake that demand its own sticky-session and proxy configuration. The connection-registry, heartbeat, and observability principles are identical; the wire format and the adapter API differ.
Related #
- Connection Lifecycle & Heartbeats — keepalive timing and dead-peer detection that the registry depends on.
- WebSocket Authentication & Authorization — gate the upgrade with JWT, origin, and CSRF validation.
- Scaling Real-Time Infrastructure — broker fan-out, presence, and delivery guarantees across a fleet.
- Real-Time Protocol Selection & Architecture — when WebSockets are the right transport versus SSE or WebRTC.
- Frontend WebSocket State Hooks & UI Patterns — the client side of the connection these servers terminate.
Back to Real-Time WebSocket Engineering