How Fanout Broadcasting Powers Millions of Concurrent WebSocket Connections — The pattern that makes millions of public chats possible<br>How Fanout Broadcasting Powers Millions of Concurrent WebSocket Connections<br>I’ve been working on a chat platform that handles millions of concurrent public chat participants. Early on, I faced the fundamental question every real-time platform asks:<br>How do you broadcast a single message to 10,000 people on the same chat simultaneously, across multiple servers, without everything collapsing?
The answer turned out to be simpler than I expected. Not simpler in implementation—the implementation is thoughtful—but simpler in concept. The pattern is called fanout broadcasting , and I want to walk through it because it’s one of those designs where understanding the shape immediately makes you understand why it scales.<br>I’ve built production systems using this pattern, and I want to share the architecture and the thinking behind it so you can apply it to your own real-time systems.<br>The Scale Problem<br>Let me start with the constraints.<br>A public chat room on your platform can hold millions of people. Each person is a WebSocket connection. A message needs to reach every connection in that room within 50 milliseconds. You have multiple servers. Each server can only see the connections it directly holds.<br>The naive approaches fail immediately:<br>Central broker : One server routes every message to every other server. Becomes the bottleneck instantly.<br>Mesh : Every server talks to every other server about every message. O(n²) complexity. Doesn’t scale.<br>Database writes : Every message hits the database. Database becomes the bottleneck.<br>These all fall into the same trap: they try to make the routing part intelligent. They fail because routing is O(n) no matter what—you have to reach every connection.<br>The fanout pattern doesn’t try to be clever. It accepts that every connection needs touching, then optimizes that to be as simple as possible.<br>The Pattern, in 30 Seconds<br>Message published to Redis Pub/Sub<br>Subscriber receives it<br>Broadcaster looks up all connections for the session<br>Queues jobs to worker pool<br>Workers send to their assigned connections<br>Done (non-blocking)<br>The magic: The broadcaster returns immediately. Workers handle delivery in the background. No blocking. No coordination. Just efficient work distribution.<br>The System Architecture<br>Here’s how all the pieces fit together:<br>Redis Pub/Sub<br>Subscriber (receives messages)<br>Broadcaster (non-blocking)<br>├─ Job Queue (bounded, drops if full)<br>Worker Pool (1,000+ workers)<br>├─ W1, W2, W3 ... W1024<br>├─ Independent, parallel processing<br>├─ 25ms timeout per send<br>Session Registry (sharded)<br>├─ Shard 0: session → [conn1, conn2, ...]<br>├─ Shard 1: session → [conn3, conn4, ...]<br>└─ Shard N: session → [...]<br>WebSocket Connections<br>├─ Connection 1 (outbound queue, reader, writer)<br>├─ Connection 2 (outbound queue, reader, writer)<br>└─ Connection N<br>Layer 1: The Session Registry (Sharded Lookup)<br>The server maintains an in-memory map of which connections belong to each session:<br>registry.shards[64]<br>├─ shard[0] → map[sessionID]map[connID]Conn<br>├─ shard[1] → map[sessionID]map[connID]Conn<br>└─ ...<br>When a WebSocket connects, it registers itself. When it disconnects, it unregisters. Simple.<br>The key insight : Use sharding to eliminate lock contention. Instead of one global lock protecting all sessions, you have 64 independent locks. When a message arrives for session #42, you hash it once, grab one lock, and you’re done.<br>Lock contention becomes imperceptible. With 40,000 connections across 1,000 sessions, you’re distributing that load across 64 buckets—each bucket sees ~15,625 connections. Contention scales down, not up.<br>Layer 2: The Message Bus (Redis Pub/Sub)<br>A shared message bus (Redis Pub/Sub) sits between publishers and the broadcaster. The flow is:<br>Publisher publishes: PUBLISH chat:room-123 '{"id":"msg-42","text":"hello"}'<br>Redis delivers to all subscribers instantly<br>Each subscriber (broadcaster instance) receives the message<br>Each processes independently, fanning out to its own connections<br>This is the crucial decoupling. The publisher doesn’t know how many subscribers exist. Each subscriber doesn’t coordinate with others. Redis just replicates the message to every listener.<br>No distributed consensus. No leader election. No coordination protocol. Just pub/sub.<br>Layer 3: The Broadcaster + Worker Pool<br>When a message arrives from Redis, the broadcaster doesn’t immediately send to all connections. That would block.<br>Instead, it creates send jobs and queues them to a worker pool :<br>Message arrives<br>Look up all connections for this session<br>Queue one job per connection to jobQueue<br>Return immediately (non-blocking)<br>Workers process queue independently<br>Each worker attempts to send (with timeout)<br>The broadcaster is fast —it just enqueues and returns. It doesn’t wait.<br>Workers run independently —Typically 1,000+ workers, each pulling...