How a Missing Jitter on WebSocket Reconnects Turned One Dropped Task Into a 41-Minute Outage
← Back
September 8, 2026NodeJS8 min read

How a Missing Jitter on WebSocket Reconnects Turned One Dropped Task Into a 41-Minute Outage

Published September 8, 20268 min read

02:47 UTC. PagerDuty: "pulse-gateway target group: 4/6 healthy." Ninety seconds later: "1/6 healthy." CPU across the whole fleet was pinned near 100%. Nobody had deployed anything since the previous afternoon.


the setup

pulse-gateway is the WebSocket service that pushes live notifications to every logged-in web and mobile client. Six ECS tasks behind an ALB, each client holds one long-lived connection, authenticated once at connect time: the gateway verifies the JWT locally, then checks a Redis-backed revocation list to make sure the session hasn't been logged out elsewhere. That Redis check runs through a single shared ioredis pool, 50 connections, sized months ago for a steady-state connect rate of roughly 20 new sessions a second.

The client SDK, a shared package used by both the web app and the mobile apps, reconnects on any unexpected close with a fixed five-second delay. It had worked fine in every prior incident, because every prior incident had dropped a handful of connections at a time, not thousands at once.


the scramble

First theory: bad deploy. The deploy log showed nothing in fourteen hours, ruled out in under a minute. Second theory: an external traffic spike, maybe a scraper or a DDoS attempt hitting the connect endpoint. ALB access logs showed the opposite of that, a wall of 101 Switching Protocols upgrade requests from client IPs that were already authenticated users, not new or unusual ones.

Third theory, and the one that actually got checked next: something was wrong with Redis itself. redis-cli --latency against the revocation-list instance showed nothing out of the ordinary, sub-millisecond round trips. Redis wasn't slow. Something in front of it was queueing.


the hunt

The connect-rate graph was the first thing that pointed anywhere useful. It didn't look like a spike, it looked like a heartbeat.

pulse-gateway connect rate, 02:42-03:02
02:42  new_connections/sec  22
02:43  new_connections/sec  19
02:43  new_connections/sec  2,180   (single 200ms window)
02:48  new_connections/sec  1,940   (single 200ms window)
02:53  new_connections/sec  1,710   (single 200ms window)
02:58  new_connections/sec  1,390   (single 200ms window)

A spike every five seconds, decaying but not going away, was a synchronized retry loop, not organic traffic. The target group history explained the trigger: one task had failed its ALB health check at 02:43:04 after an unrelated memory pressure alarm on that container instance, and ALB deregistered it. That's routine. It happens most weeks and nobody pages for it. What made this one different was that the task was holding roughly 2,180 live WebSocket connections at the moment it dropped, and none of them closed gracefully, they all saw an abrupt 1006.

client SDK reconnect logic, as shipped
ws.onclose = () => {
  setTimeout(connect, 5000);
};

Every one of those 2,180 clients received its close event within roughly the same 200ms window and scheduled the exact same five-second timer. Five seconds later, all of them tried to reconnect at once. Each reconnect meant a fresh TLS handshake plus a Redis round trip to check revocation status, and 2,180 concurrent Redis requests queued hard behind a 50-connection pool sized for 20 requests a second, not two thousand in under a second.

clinic doctor against a running task ruled out a blocked event loop. What it showed instead was a huge number of pending promises, all waiting on the same starved pool. CPU climbed from handling that many concurrent TLS handshakes and JSON parses at once, not from any single slow operation. That CPU spike delayed the process's own /healthz endpoint, which lived on the same event loop behind the same queue of pending work.

/healthz, as shipped
app.get('/healthz', async (req, res) => {
  await redis.ping(); // shares the pool with every connect-time revocation check
  res.sendStatus(200);
});

A health check that shares a resource with the thing that's overloaded will report unhealthy right when reporting unhealthy does the most damage. ALB pulled a second task, then a third, concentrating the same retrying clients onto fewer and fewer targets while the five-second timer kept firing on schedule underneath all of it.


the find

Root cause: a fixed, unjittered reconnect delay turned one routine deregistration into a synchronized stampede, and a Redis pool sized for steady-state load, shared with the health check, became the actual bottleneck once concurrency jumped roughly a hundredfold in under a second. The health check failing under that load is what let one dropped task cascade into five.


the fix

First, the client SDK. Full jitter, not a fixed delay and not jitter added on top of a fixed delay, since either of those still clusters retries within a visible window.

client SDK reconnect logic, after
let attempt = 0;

function scheduleReconnect() {
  const base = 500;
  const cap = 10_000;
  const backoff = Math.min(cap, base * 2 ** attempt);
  const delay = Math.random() * backoff; // full jitter, not fixed + random
  attempt += 1;
  setTimeout(connect, delay);
}

ws.onclose = scheduleReconnect;
ws.onopen = () => { attempt = 0; };

Second, the health check no longer depends on anything a connection stampede can starve.

/healthz, after
app.get('/healthz', (req, res) => res.sendStatus(200));

app.get('/readyz', async (req, res) => {
  const ok = await redis.ping().then(() => true).catch(() => false);
  res.sendStatus(ok ? 200 : 503);
});

/healthz answers liveness only, no dependency, so a busy but functioning task never gets pulled from rotation for being busy. /readyz still checks Redis, but it's used for startup gating, not for deciding whether to deregister an already-running task under load.

Third, the connect path caps how much concurrent auth work it accepts rather than letting every upgrade request queue behind the same starved pool.

connection accept, after
const acceptLimiter = new TokenBucket({ capacity: 200, refillPerSec: 100 });

server.on('upgrade', (req, socket, head) => {
  if (!acceptLimiter.tryRemove(1)) {
    socket.write('HTTP/1.1 503 Service Unavailable\r\nRetry-After: 2\r\n\r\n');
    socket.destroy();
    return;
  }
  wss.handleUpgrade(req, socket, head, onConnection);
});

A client rejected with 503 during a burst reconnects on its own jittered backoff instead of queueing invisibly on the server side. Excess load turns into a fast, explicit rejection instead of a slow, silent pileup.


the aftermath

2,180 Reconnects in a single 200ms window from one dropped task
6 → 1 Healthy targets in the ALB target group at the low point
41 min From first page to all six tasks back in rotation
~180/sec Peak reconnect rate observed after jitter, replayed in staging

One dropped task is not supposed to be an incident. It's supposed to be a line in a target group history that nobody looks at twice. The gap here wasn't in the load balancer or in Redis, both behaved exactly as configured. It was in a reconnect timer that had never been tested against the shape of load it would actually see the day thousands of clients lost their connection at once instead of one at a time.

  • A fixed reconnect delay is a synchronization mechanism whether you intend it as one or not. Any client population large enough will eventually share a disconnect event, and a shared timer turns that into a shared retry.
  • A health check that depends on the same resource pool as the workload it's checking will report failure exactly when reporting failure causes the most collateral damage. Liveness and readiness are different questions and deserve different endpoints.
  • Connection pools sized for steady state are a hidden capacity ceiling for burst events. The pool wasn't wrong for 20 connects a second, it was never sized against the number of clients one task could be holding at once.
  • Rejecting excess load fast and explicitly, with a status code the client already knows how to back off from, is safer than letting it queue somewhere invisible until something else times out first.

The token bucket's refill rate of 100 a second isn't a permanent ceiling, it's a starting point chosen to stay comfortably under what the Redis pool can absorb. It gets revisited the next time either side of that number changes.

Share this
← All Posts8 min read