WebSocket gateways need sticky affinity: the process that holds the socket must receive the pushes. Naive modulo hashing reshuffles almost everyone when you add a node; consistent hashing moves only ~1/N. Seniors combine a hash ring with graceful handoff so scale-in drains sockets instead of hard-cutting them mid-conversation.
⚡ TL;DR: Put gateways on a hash ring keyed by
connectionIdoruserId. On scale events, migrate only the affected arc; drain withGOAWAY-style close codes and client reconnect jitter. Pair with API Gateway WebSocket Fan-Out and Graceful Shutdown for Node.
Ring basics in Node
// ws/hash-ring.ts
import { createHash } from "node:crypto";
export class HashRing {
private ring: { hash: number; node: string }[] = [];
constructor(nodes: string[], vnode = 64) {
for (const n of nodes) {
for (let i = 0; i < vnode; i++) {
const hash = this.h(`${n}#${i}`);
this.ring.push({ hash, node: n });
}
}
this.ring.sort((a, b) => a.hash - b.hash);
}
private h(s: string) {
return createHash("sha256").update(s).digest().readUInt32BE(0);
}
owner(key: string): string {
const x = this.h(key);
const i = this.ring.findIndex((e) => e.hash >= x);
return (this.ring[i] ?? this.ring[0]).node; // ✅ wrap
}
}
❌ nodes[crc32(userId) % nodes.length] — adding one gateway remaps most users.
Sticky registry and push path
// ws/registry.ts
export async function bindConnection(connId: string, gatewayId: string) {
await redis.set(`ws:${connId}`, gatewayId, "EX", 3600);
}
export async function publishToUser(userId: string, payload: unknown) {
const conns = await redis.smembers(`user:${userId}:conns`);
await Promise.all(
conns.map(async (connId) => {
const gw = await redis.get(`ws:${connId}`);
if (!gw) return;
// ✅ Direct to owning gateway; no broadcast storm
await bus.send(gw, { connId, payload });
})
);
}
Graceful migration on scale-in
// ws/drain.ts
export async function drainGateway(gw: string, ring: HashRing) {
const local = listLocalConnections();
for (const c of local) {
if (ring.owner(c.userId) === gw) continue; // still ours
// Soft close — client reconnects with jitter into new owner
c.socket.close(4001, "migrate");
await sleep(5 + Math.random() * 50);
}
await deregisterFromRing(gw);
}
| Event | Action |
|---|---|
| Scale-out | Add node to ring; optionally proactive migrate arc |
| Scale-in | Drain first; remove from ring after 0 local sockets |
| Crash | Clients reconnect; registry TTL expires stale binds |
| Deploy | Same as scale-in per task |
Client reconnect jitter
// client reconnect (browser / RN)
function backoff(attempt: number) {
const base = Math.min(1000 * 2 ** attempt, 15000);
return base * (0.5 + Math.random()); // ✅ avoid reconnect storms
}
Coordinate with API Gateway WebSocket Fan-Out so reconnect storms do not amplify pushes.
Closing checklist
✅ Dos
– ✅ Use consistent hashing with vnodes
– ✅ Bind connection→gateway in a TTL registry
– ✅ Drain before removing a node from the ring
– ✅ Soft-close with reconnect jitter
– ✅ Push only to owning gateway
❌ Don’ts
– ❌ Don’t use modulo hashing for sticky WS
– ❌ Don’t broadcast every message to all gateways
– ❌ Don’t kill tasks without drain
– ❌ Don’t forget registry TTL after crashes
– ❌ Don’t reconnect all clients on the same millisecond
Related reading
- API Gateway WebSocket Fan-Out: Coalesce Pushes After Reconnect Storms
- Graceful Shutdown for Node: Drain Correctly on Kubernetes and ECS
- HTTP/2 in Node Behind ALB: Multiplexing Pitfalls
- Cell-Based Architecture on AWS
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
