Build Slack's Channel Message Fan-Out and Unread Counts
distributed-systems scalability performance
Slack Message Fan-Out System
Delivering one message to thousands of channel members in real-time, while keeping unread counts accurate across every device you own.
You are typing in a Slack channel with 8,000 members. You hit Enter. Within 150 milliseconds, every online member’s screen shows a new message. Within the same window, their unread badge increments by exactly one. Open Slack on your phone - same badge count. Close the channel on desktop, open it on your iPad - the badge clears. The server knows you read it.
This is not magic. It is a precisely engineered fan-out pipeline that touches half a dozen distributed systems in under two hundred milliseconds. The deceptive simplicity of the Slack UI hides one of the hardest problems in real-time systems: how do you push a single event to thousands of subscribers without either crushing your write throughput or leaving users staring at stale data?
Think of it like a radio broadcast tower. One station transmits. Ten million radios receive. The trick is that every radio has a different signal strength, some channels have 5 members and some have 50,000, and you also need to track which stations each listener has tuned into and for how long. That is the Slack fan-out problem in one sentence.
Three decisions define the entire architecture. First, how do you store and look up the list of channel members efficiently for channels that range from 2 to 500,000 users? Second, how do you fan out a message to all of them within your latency budget without blowing up your write amplification? And third, how do you maintain per-user per-channel unread counts that stay accurate even when users open the same channel on three devices simultaneously?
Requirements and Constraints
Functional Requirements
- Send a message to a channel and deliver it in real-time to all online members
- Maintain accurate unread message counts per user per channel
- Track a read cursor (last-read position) per user per channel
- Sync read state across all devices a user has logged in on
- Support channels from 2 to 500,000 members (enterprise grid workspaces)
- Handle edit and delete events propagating to all members
- Push notifications to offline users via APNs / FCM
Non-Functional Requirements
| Metric | Target |
|---|---|
| Message delivery p99 latency | < 200ms for online users |
| Unread count accuracy | Eventual, within 1 second |
| Cross-device sync latency | < 500ms |
| Fan-out write throughput | 500K messages/second peak |
| WebSocket connections | 10M concurrent (system-wide) |
| Channel member list reads | < 5ms at p99 |
| Unread counter reads | < 2ms at p99 |
| System availability | 99.99% |
Traffic Estimates
Slack processes roughly 1.5 billion messages per day. At peak, that is ~20,000 messages per second. With an average of 50 channel members, that is 1 million fan-out write operations per second at peak. Counter increments run at 2-5x the message rate due to multi-device registrations.
High-Level Architecture
The system decomposes into six primary components: a WebSocket Gateway, a Message Service, a Fan-Out Engine backed by Kafka, a Channel Member Store, a Redis-based Unread Counter service, and a Read Cursor Store.
The data flow for a sent message proceeds as follows. The sender’s client sends a chat.postMessage payload over the persistent WebSocket connection. The Gateway authenticates the request, verifies channel membership, and forwards to the Message Service. The Message Service assigns a monotonic timestamp-based ID (Slack calls this msg_ts), persists the message to the message store, and publishes a channel_message event to a Kafka topic partitioned by channel_id. Fan-Out Workers consume from Kafka, look up the channel member list from Redis, and in parallel push to online WebSocket connections and increment Redis unread counters for all members. Offline users receive mobile push notifications via APNs/FCM.
Channel Subscriber Management
Every channel maintains a member list. Querying this list on every message send is the hottest read in the system - it must be fast.
Storage Strategy
For channels up to 10,000 members, the member list lives as a Redis Set using the key channel:members:{channel_id}. The value is a set of user_id strings. SMEMBERS returns all members in O(N) but N is bounded and the operation completes in microseconds for typical channel sizes.
# Add member to channel
SADD channel:members:C012345 U001 U002 U003
# Get all members for fan-out
SMEMBERS channel:members:C012345
# Check membership (used at message send to verify auth)
SISMEMBER channel:members:C012345 U001
# Get member count
SCARD channel:members:C012345
For large channels (10,000+ members), SMEMBERS returning all user IDs in one shot becomes expensive both in memory and network transfer. The system switches to a paginated approach using Redis Sorted Sets, where the score is the member’s join timestamp. The Fan-Out Worker iterates using ZSCAN in batches of 1,000.
# Large channel: sorted set keyed by join time
ZADD channel:members:large:C099999 1717200000 U001
ZADD channel:members:large:C099999 1717200001 U002
# Paginated scan for fan-out workers
ZSCAN channel:members:large:C099999 0 COUNT 1000Premium Content
Unlock the full article along with everything else in the archive — all in one place.