Consistent Hashing for WebSockets: Sticky Gateways During Scale Events

Consistent Hashing for WebSockets: Sticky Gateways During Scale Events

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 connectionId or userId. On scale events, migrate only the affected arc; drain with GOAWAY-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

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply