A 30-second network blip can bounce tens of thousands of sockets. Each reconnect triggers presence fan-out, missed-message catch-up, and auth refreshes — a thundering herd that looks like a DDoS against your own postToConnection path. Seniors coalesce pushes, jitter catch-up, and shed non-critical fan-out under pressure.
⚡ TL;DR: Buffer outbound events per connection with a short coalesce window; collapse duplicate topics. Jitter reconnect catch-up. Cap postToConnection concurrency and drop ephemeral presence under load. Pair with Consistent Hashing for WebSockets and Kafka to Lambda Backpressure.
Coalesce buffer per connection
// ws/coalesce.ts
type Outbound = { topic: string; payload: unknown; ts: number };
const buffers = new Map<string, Map<string, Outbound>>(); // connId → topic → last
export function enqueue(connId: string, msg: Outbound, windowMs = 50) {
let topics = buffers.get(connId);
if (!topics) {
topics = new Map();
buffers.set(connId, topics);
setTimeout(() => flush(connId), windowMs); // ✅ one timer per conn
}
topics.set(msg.topic, msg); // collapse duplicates by topic
}
async function flush(connId: string) {
const topics = buffers.get(connId);
buffers.delete(connId);
if (!topics) return;
const batch = [...topics.values()];
await apiGw.postToConnection({
ConnectionId: connId,
Data: JSON.stringify({ type: "batch", items: batch }),
});
}
❌ Firing postToConnection once per upstream event during a reconnect storm — you will hit 429s and amplify retries.
Jittered catch-up after reconnect
// ws/catchup.ts
export async function onConnect(connId: string, userId: string) {
const delay = 200 + Math.random() * 1800; // ✅ spread load
await sleep(delay);
const missed = await loadMissed(userId, since: lastAck(userId));
// Cap catch-up size — rest goes to "refresh from snapshot"
const slice = missed.slice(0, 100);
for (const m of slice) enqueue(connId, m, 50);
if (missed.length > 100) enqueue(connId, snapshotHint(userId), 0);
}
Fan-out backpressure
// ws/fanout.ts
import { Semaphore } from "./sem";
const postSem = new Semaphore(200); // global postToConnection concurrency
export async function fanout(connIds: string[], payload: Outbound) {
const critical = payload.topic === "billing" || payload.topic === "security";
if (!critical && postSem.pressure() > 0.8) {
metrics.hit("FanoutShed");
return; // ✅ shed presence/typing indicators
}
await Promise.all(
connIds.map((id) =>
postSem.run(() =>
apiGw.postToConnection({ ConnectionId: id, Data: JSON.stringify(payload) })
.catch((e) => {
if (e.statusCode === 410) return registry.drop(id); // gone
throw e;
})
)
)
);
}
| Load signal | Action |
|---|---|
| postToConnection 429 | Increase coalesce window, lower concurrency |
| Reconnect rate spike | Enable catch-up jitter + snapshot hints |
| Presence QPS high | Shed ephemeral topics first |
| DLQ on push worker | Stop retries of ephemeral; keep critical |
Connection registry hygiene
Stale connections after a blip produce 410 Gone storms. Sweep aggressively and treat 410 as success for delete. Same lease mindset as Lease-Based ECS Leadership — expire what you cannot heartbeat.
Closing checklist
✅ Dos
– ✅ Coalesce by topic per connection with a short window
– ✅ Jitter reconnect catch-up
– ✅ Cap postToConnection concurrency
– ✅ Shed ephemeral fan-out under pressure
– ✅ Treat 410 as registry delete, not a hard error
❌ Don’ts
– ❌ Don’t push every presence tick during reconnect spikes
– ❌ Don’t replay unbounded missed-message histories
– ❌ Don’t retry ephemeral topics forever
– ❌ Don’t synchronize all clients on the same reconnect timer
– ❌ Don’t ignore 429s from API Gateway as “transient noise”
Related reading
- Consistent Hashing for WebSockets: Sticky Gateways During Scale Events
- Kafka to Lambda Backpressure: Lag SLOs That Protect Downstream Sinks
- Graceful Shutdown for Node
- Lease-Based ECS Leadership
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
