WebSocket reconnect storm: one pod restart dropped 50K sockets and the cluster
A routine rolling deploy restarted one realtime pod holding 50,000 WebSocket connections. Every client reconnected within two seconds, the auth and history lookups melted, and the storm kept itself alive for 25 minutes. Here is how we drain sockets, pace reconnects and make resume cheap.
At 14:02 on a Thursday we shipped a one-line change to the realtime gateway, the Node.js service that holds WebSocket connections for our team-chat and notifications product. The rollout replaced pods one at a time. When the first old pod terminated, error rates across the whole product went to 40 percent and stayed there for 25 minutes, long after the rollout had finished.
The first alerts came from the ingress layer:
2024-11-14 14:02:19 [alert] 31#31: 4096 worker_connections are not enough while connecting to upstream
2024-11-14 14:02:21 [error] 29#29: *8812201 upstream timed out (110: Connection timed out) while reading response header from upstream
Then from the gateway pods themselves:
Error: accept EMFILE
at TCP.onconnection (node:net:2122:24)
And finally from the session store: PostgreSQL CPU at 100 percent, with the top query being the "messages since cursor" lookup that clients run after every reconnect.
Reproducing it
In a staging cluster with six gateway pods we opened 60,000 connections with a load script that mimicked our real client: on close, wait one second, reconnect, authenticate, then fetch everything since the last message id. Deleting one pod with kubectl delete pod reproduced the outage every time. With the reconnect delay randomised, the same test produced a brief bump and no errors.
What we ruled out
- The code change. Rolling back did nothing; the storm continued because it no longer depended on which version was running.
- Pod resources. Gateway pods had headroom for 15,000 steady connections each. The problem was the rate of new connections, not the count.
- The load balancer algorithm alone. Ingress used round robin, so the 50,000 returning clients were spread fairly. Fair spreading of a 50,000 handshake spike still overwhelmed every pod.
Root cause
One pod held about 50,000 connections. When Kubernetes sent SIGTERM, our process exited almost immediately, closing all of them at the same instant. Every client used a fixed one second reconnect delay, so 50,000 TLS handshakes, JWT verifications, presence writes and history queries arrived inside roughly two seconds. The history query hit PostgreSQL hard, handshakes queued, clients timed out after five seconds, gave up, waited one second and tried again. Failed attempts multiplied the load, so the remaining pods began dropping their own healthy connections under memory pressure, which added more clients to the storm. The system had a stable bad state and no mechanism to leave it.
What is happening under the hood
A long-lived connection is cheap to keep and expensive to establish. For our gateway, steady state cost about 30 KB of memory and near-zero CPU per socket. A new connection cost a TCP and TLS handshake at the ingress, an HTTP upgrade, token verification, a Redis presence write, a channel subscription, and a database query to backfill missed messages. Measured in staging, one reconnect cost about 400 times the CPU of one minute of idle connection.
Capacity planning for realtime systems usually looks at concurrent connections. The dangerous number is connections per second during a mass disconnect. Anything that closes many sockets at once creates that spike: a pod restart, a node drain, an ingress reload, a load balancer idle timeout aligned across clients, or a mobile network blip.
Synchronised clients are what turn a spike into a storm. If every client waits the same delay, their retries stay aligned. If failed handshakes are retried faster than the backend recovers, the load never drops below capacity.
Environment
| Component | Version / value |
|---|---|
| Platform | Amazon EKS 1.30, m6i.xlarge nodes |
| Ingress | ingress-nginx 1.11 (nginx 1.25) behind an NLB |
| Runtime | Node.js 20.18 |
| WebSocket library | ws 8.18.0 |
| Presence / pubsub | Redis 7.2 |
| History store | PostgreSQL 15.8 |
| Client reconnect | fixed 1000 ms, 5 s handshake timeout |
| Pod shutdown | terminationGracePeriodSeconds: 30, no preStop hook |
Minimal reproduction
The client logic that caused it, cut down:
// old-client.js
function connect() {
const ws = new WebSocket("wss://rt.example.com/socket");
ws.onopen = () => ws.send(JSON.stringify({ type: "resume", since: lastId }));
ws.onclose = () => setTimeout(connect, 1000); // every client, same delay
}
connect();
And the server shutdown:
// old-server.js (excerpt)
process.on("SIGTERM", () => process.exit(0));
Who is affected
Any service holding many long-lived connections per instance: WebSocket gateways, SSE endpoints, MQTT brokers, gRPC streams. The risk grows with connections per instance and with the cost of the reconnect path. Clients with fixed delays or no handshake timeout backoff are the multiplier.
This is a paid story
Subscribe to read the full content, access all paid stories, and support the authors.
Add a label
Embed this post
<iframe src="https://www.hibug.me/websocket-reconnect-storm-after-restart" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I am a Senior Software Engineer with over a decade of hands-on experience building, scaling, and maintaining complex systems across the full stack—spanning frontend, backend, and DevOps. Over the last 10 years, I have navigated diverse architectures and untangled some of the most frustrating, elusive bugs in production. I believe that every production incident is a massive learning opportunity. Having discovered hibug.me, I am excited to contribute to this knowledge base, breaking down root causes, and sharing verified fixes to help fellow developers debug faster and build more resilient systems.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.