CONCEPT · 45 minutes
Design a real-time messaging system with per-connection fan-out using mpsc channels and a connection registry.
When I built the cross-agent communication layer for workflow-engine-rs, the core problem was not sending a message — it was delivering that message to every subscriber who was listening, without one slow subscriber blocking the others.
This is the fan-out problem: when a message is sent to a channel, every connected subscriber must receive it. The naive implementation — the thing I'd write on a first pass, and you probably would too — is to iterate over subscribers and write to each one. It has a critical flaw: a slow subscriber (one whose write buffer is full, one on a poor network) blocks the iteration. While you are waiting for the slow client, no other subscriber receives the message. The more subscribers you have, the more catastrophic a single slow client becomes.
The fix is the pattern I use in workflow-engine-rs: per-connection channels. Each subscriber gets its own mpsc (multi-producer, single-consumer) channel. When a message needs to be delivered, the dispatcher sends to all channel senders simultaneously — a non-blocking operation. Each subscriber's receiver independently pulls from its own channel. A slow subscriber's channel fills up; the dispatcher is not blocked.
Think of it like a radio station and its listeners. The station broadcasts continuously. Each listener's radio has its own receive buffer. A listener who walks into a tunnel (slow connection) misses signals into their buffer, but the station keeps broadcasting for all other listeners. The station is never blocked by any single listener's reception quality.
The two responsibilities of a real-time messaging backend: (1) Fan-out — delivering a message to all subscribers of a channel; (2) Routing — knowing which connections are subscribed to which channels. A connection registry handles routing; per-connection channels handle fan-out.
This module walks through the fan-out design with per-connection channels and a connection registry, the protocol choice for real-time delivery, failure modes at scale, and the scale limit that forces a distributed design.
The fan-out pattern has two components: a connection registry that maps channel IDs to a set of sender handles, and a per-connection receive loop that reads from the connection's channel and writes to the WebSocket.
flowchart TD
M[Incoming Message\nto channel: "room-42"] --> R[Connection Registry\nchannel_id -> set of senders]
R -->|"send(msg)"| S1["Sender A\n(non-blocking)"]
R -->|"send(msg)"| S2["Sender B\n(non-blocking)"]
R -->|"send(msg)"| S3["Sender C\n(non-blocking)"]
S1 --> Q1["Channel A\n(bounded queue)"]
S2 --> Q2["Channel B\n(bounded queue)"]
S3 --> Q3["Channel C\n(bounded queue)"]
Q1 --> WS1["WebSocket\nClient A"]
Q2 --> WS2["WebSocket\nClient B"]
Q3 --> WS3["WebSocket\nClient C"]
style R fill:#3b82f6,color:#fff
style S1 fill:#d97706,color:#fff
style S2 fill:#d97706,color:#fff
style S3 fill:#d97706,color:#fffThe dispatcher sends to all sender handles for a channel. Each send is non-blocking — it either places the message in the channel's queue or fails immediately if the queue is full. The dispatcher never waits for any subscriber to receive.
Each subscriber's receive loop runs independently on its own task or thread. It reads from the channel and writes to the WebSocket at whatever pace the client can handle.
The try_send call is non-blocking: if the channel is full (the client is slow), the message is dropped for that subscriber without affecting others. This is the right behavior — you accept that slow clients may miss messages rather than block the entire fan-out.
The slow subscriber failure mode: If a subscriber's channel queue fills up, messages are dropped. Depending on your guarantees, this may be acceptable (live chat — missed messages are visible on reconnect) or unacceptable (financial notifications — every message must be delivered). For the latter, you need durable message storage and a delivery-confirmation loop, not a simple fan-out.
When a client disconnects, you must remove its sender from the registry. Otherwise, subsequent fan-out calls attempt to send to a closed channel, generating errors for every message.
This must run whether the client disconnects cleanly (WebSocket close frame) or abruptly (network drop). In practice: wrap each per-connection task in a cleanup handler that calls unsubscribe when the task exits, for any reason.
A single-server fan-out with per-connection channels scales to tens of thousands of concurrent WebSocket connections on a single server. Memory consumption is the practical limit — each channel queue and connection handle takes memory, and a message being fanned out to N subscribers creates N copies in the channel queues.
The scale wall is reached when your users are distributed across multiple servers. Subscriber A might be connected to Server 1 while Subscriber B is connected to Server 2. A message sent to a channel must reach both — but Server 1's registry has no knowledge of Subscriber B.
The standard solution: a message bus (Redis Pub/Sub, NATS, or Kafka) as a cross-server fan-out layer.
Each server subscribes to the Redis channels its local clients are subscribed to. When a message is published — from any server — Redis delivers it to every subscribed server. Each server then fans out locally to its connected clients. The result: a message sent from any server reaches all subscribers, regardless of which server they are connected to.
Redis Pub/Sub is at-most-once: Redis Pub/Sub does not persist messages. If a server is briefly disconnected from Redis when a message is published, it misses it. For at-least-once delivery across servers, use Kafka (durable, consumer groups, replay) or a Redis Streams subscription. At-most-once is fine for live chat where "missed if offline" is acceptable; it is not fine for transactional notifications.
Before the exercise, verify you can answer these from memory:
try_send (non-blocking) rather than send (blocking) matter in the fan-out loop?unsubscribe on disconnect?Reflective prompt: In the cross-agent communication layer I built for workflow-engine-rs, agents are both producers and consumers on the same channels. When agent A sends a message, should agent A receive its own message from the fan-out? What is the design decision here, and what are the implications for the registry? Write two sentences before moving to the exercise.
Apply the fan-out design to a concrete system.
The task: Design a real-time collaborative document editing notification system. When a user saves a change to a document, all other users viewing that document should see a "document updated" notification within 1 second. The system must handle 10,000 active documents with an average of 5 simultaneous viewers each.
<CodeExample title="Design Prompt Starter — Collaborative Editing Notifications" language="markdown" code={`## Requirements
Apply real-time messaging design principles to realistic scenarios
1. A fan-out loop iterates over all subscriber connections and calls a blocking write to each. Subscriber B is on a slow network and the write blocks for 5 seconds. What happens to Subscriber A and C?
Correct Answer: B
A blocking write inside an iteration loop serializes all delivery. While the loop is blocked writing to Subscriber B, Subscribers A and C are waiting. The entire fan-out is as slow as the slowest subscriber. Per-connection channels with try_send (non-blocking) fix this: the dispatcher sends to all channel senders immediately, and each subscriber's receive loop independently manages its own delivery pace.
2. A client disconnects abruptly (network drop). The connection registry still holds a sender handle for that connection. What happens on the next fan-out to that channel?
Correct Answer: B
A closed channel's sender returns an error on try_send. If the registry is not cleaned up on disconnect, dead senders accumulate — every fan-out generates an error for every dead connection. In a long-running server, this leaks memory and CPU. The fix: each per-connection task runs a cleanup handler that calls unsubscribe when the task exits, for any reason — clean disconnect, network drop, or panic.
3. Why is Redis Pub/Sub necessary for a multi-server real-time messaging deployment?
Correct Answer: B
Each server's registry holds only the connections established with that server. If Subscriber A is on Server 1 and Subscriber B is on Server 2, a message sent via Server 1's registry will reach Subscriber A but not Subscriber B. Redis Pub/Sub broadcasts the message to all subscribed servers; each server fans out to its own local subscribers, enabling cross-server delivery.
4. A server is briefly disconnected from Redis Pub/Sub for 3 seconds. What messages does it miss?
Correct Answer: B
Redis Pub/Sub is at-most-once: messages are not persisted. If a subscriber (server) is disconnected when a message is published, it is missed permanently. For a chat system where missing messages during a brief network hiccup is acceptable (the client can reload), this is fine. For guaranteed delivery, use Redis Streams (which persist messages) or Kafka instead.
5. A per-connection channel has a bounded queue of 100 messages. A slow client's queue fills up. What should try_send return and what should the server do?
Correct Answer: B
try_send returns an error (channel full) without blocking. The server should not silently discard: log the drop and increment a metric. After too many drops, consider disconnecting the slow client — a client whose queue is always full is not reading, and keeping its channel open wastes memory. The metric is also your signal that a client population is systematically slow — which may indicate a client-side bug.
6. Should a notification system where clients only receive messages (never send) use WebSocket or SSE?
Correct Answer: B
SSE is a unidirectional server-to-client push protocol over HTTP. It auto-reconnects, works through HTTP/1.1 proxies, and requires no protocol upgrade. For a pure notification system where clients never send messages, SSE is simpler and more appropriate than WebSocket. WebSocket is the right choice only when you need bidirectional communication — clients sending and receiving on the same persistent connection.