Skip to the solution
HardSystem Design17 min read

How do you scale WebSockets to 1M connections with Redis Pub/Sub?

829 views

The problem

Question presented to candidate: "We have a chat and live-update product that is about to hit a million concurrent WebSocket connections. One server cannot hold them all, and a message sent by a user on one server has to reach recipients on other servers. Walk me through how you would build this with Redis Pub/Sub, with numbers, and tell me what breaks first."

What a strong answer should cover:

  • A gateway tier that only holds sockets, sized with a worked estimate (connections per node, memory per connection, headroom for a lost node), plus OS limits: file descriptors and the ephemeral port range between a proxy and a backend
  • Cross-node fan-out with Redis Pub/Sub: gateways subscribe only to channels for rooms their sockets are in, and the publish path is persist first, then publish
  • Channel design trade-offs (one global channel vs per-room vs per-gateway) and when each wastes work; sharded Pub/Sub (Redis 7.0, SPUBLISH/SSUBSCRIBE) so cluster traffic stays within one shard
  • Redis Pub/Sub is at-most-once and keeps nothing: pair it with a durable log and client resume by sequence number
  • Slow consumers and backpressure: the client-output-buffer-limit pubsub cutoff, per-socket send queues, and what happens at 10x (hot rooms, reconnect storms)

Clarifying questions expected:

  • What is the room size distribution (mostly 1:1 and small groups, or a few huge broadcast rooms)?
  • Message rate and size at peak, and is delivery allowed to be lossy (typing indicators) or must it be reliable (messages)?
  • Is there an existing durable store for messages, and how long must a disconnected client be able to resume?

Code / implementation expected: Optional โ€” a design with numbers; the example simulates the fan-out counts, the cluster hash slot and a slow-subscriber cutoff in four languages.

  • websocket
  • redis
  • pubsub

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

17 min read ยท 14 sections

Read the code

1M connections over Redis Pub/Sub: global vs per-room channels, cluster hash slots, slow-subscriber cutoff
// 1M WebSocket connections behind 25 gateway nodes, with Redis Pub/Sub simulated in-process.
// Counts what Redis must send to gateways under two subscription designs, computes the
// Redis Cluster hash slot of channel names, and replays a slow-subscriber buffer limit.
let seed = 42;
const rand = (n) => { seed = (seed * 48271) % 2147483647; return seed % n; }; // MINSTD, fixed seed

const CONNS = 1000000, NODES = 25, MESSAGES = 5000;
console.log("Part 1: capacity (assumptions: 30 KB per connection, 25% headroom)");
const perNode = CONNS / NODES;
console.log("  " + NODES + " nodes x " + perNode + " connections = " + CONNS + "; at 80% fill each node can take " + (perNode / 0.8));
console.log("  memory per node: " + perNode + " x 30 KB = " + (perNode * 30 / 1000000).toFixed(1) + " GB");

function scenario(name, rooms) {
  const roomOf = new Int32Array(CONNS);
  const size = new Int32Array(rooms);
  const nodesMask = new Int32Array(rooms); // bit i set = node i has a member of this room
  for (let c = 0; c < CONNS; c++) {
    const node = rand(NODES), room = rand(rooms);
    roomOf[c] = room; size[room]++; nodesMask[room] |= 1 << node;
  }
  let globalSends = 0, perRoomSends = 0, sockets = 0, wasted = 0;
  for (let m = 0; m < MESSAGES; m++) {
    const room = rand(rooms);
    let nodes = 0;
    for (let i = 0; i < NODES; i++) if (nodesMask[room] & (1 << i)) nodes++;
    globalSends += NODES;          // every gateway subscribes to one channel "all"
    perRoomSends += nodes;         // gateways subscribe to "room:<id>" only while they hold a member
    wasted += NODES - nodes;
    sockets += size[room];
  }
  console.log("  " + name + ": " + rooms + " rooms, avg " + CONNS / rooms + " members");
  console.log("    one global channel: " + globalSends + " Redis->gateway sends, " + wasted + " of them to gateways with nobody to tell");
  console.log("    channel per room:   " + perRoomSends + " Redis->gateway sends (" + Math.round(100 * perRoomSends / globalSends) + "% of global)");
  console.log("    socket writes by gateways: " + sockets);
}
console.log("\nPart 2: " + MESSAGES + " messages to random rooms");
scenario("small rooms", 250000);
scenario("big rooms", 1000);

function crc16(s) { // CRC16-XMODEM, the function Redis Cluster uses for key slots
  let crc = 0;
  for (let i = 0; i < s.length; i++) {
    crc ^= s.charCodeAt(i) << 8;
    for (let b = 0; b < 8; b++) crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff;
  }
  return crc;
}
function slot(key) { // only the part inside the first {...} is hashed, if it is non-empty
  const open = key.indexOf("{");
  if (open >= 0) { const close = key.indexOf("}", open + 1); if (close > open + 1) key = key.slice(open + 1, close); }
  return crc16(key) % 16384;
}
console.log("\nPart 3: sharded Pub/Sub routes a channel by hash slot (3 shards, even split)");
console.log("  crc16(\"123456789\") = 0x" + crc16("123456789").toString(16).toUpperCase());
const shardOf = (s) => (s < 5461 ? "shard-1" : s < 10923 ? "shard-2" : "shard-3");
for (const ch of ["room:41", "room:42", "room:43", "{room:42}:typing", "{room:42}:presence"]) {
  const s = slot(ch);
  console.log("  " + ch.padEnd(20) + " slot " + String(s).padStart(5) + " -> " + shardOf(s));
}

console.log("\nPart 4: a gateway reads 800 msg/s while 1000 msg/s of 1 KB arrive");
const SOFT = 8 * 1024 * 1024, HARD = 32 * 1024 * 1024, SOFT_SECONDS = 60;
let buf = 0, overSince = -1;
for (let t = 1; t <= 300; t++) {
  buf += (1000 - 800) * 1024;
  if (buf > SOFT && overSince < 0) { overSince = t; console.log("  t=" + t + "s buffer " + (buf / 1048576).toFixed(2) + " MB passes the 8 MB soft limit"); }
  if (buf > HARD || (overSince >= 0 && t - overSince >= SOFT_SECONDS)) {
    console.log("  t=" + t + "s buffer " + (buf / 1048576).toFixed(2) + " MB: Redis closes the subscriber; " + (t * 1000 - t * 800) + " messages were still unread");
    break;
  }
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 68 of 106 in System design

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextHard

What are event sourcing and CQRS?

Continue