System design deep dive · HLD
Design Slack: high-level design for real-time team messaging.
Requirements, back-of-envelope capacity estimation, a high-level design diagram, a deeper deployment architecture diagram, sequence diagrams for the send/fanout path and the reconnect/catch-up path, and an entity-relationship diagram for the data model - with the reasoning an interviewer expects behind every box and arrow.
1. Clarify requirements before drawing any box
Team chat has a much wider blast radius than most CRUD systems: the same "send a message" action has to work identically whether it lands in a 3-person DM or a 10,000-member company-wide channel. Pinning down that variance up front is what shapes the whole design.
Functional requirements
Non-functional requirements
2. Back-of-the-envelope capacity estimation
The number that matters most here is not how many messages are sent - it's how many times each one has to be delivered. That distinction, fanout amplification, is what makes this system's scaling story different from a typical write path.
| Metric | Assumption | Resulting estimate |
|---|---|---|
| Messages sent (writes) | 10M daily active users × ~40 messages/user/day | ≈400M messages/day → ~4,600 msg/sec average |
| Peak send rate | business-hour overlap across time zones | ~30,000 msg/sec peak, roughly 6-7× average |
| Messages delivered (fanout) | one send into a 10,000-member channel with, say, 500 currently online | up to 500-10,000 real-time pushes per send - fanout, not ingest, dominates cost by 10-100× |
| Message storage | 400M msgs/day × ~1KB (text + metadata) × 365 days | ≈150 TB/year raw; low-to-mid hundreds of TB/year once multi-year retention + replication is added |
| Search index volume | a meaningful fraction of the same message volume, partitioned per workspace | indexing lag budget of a few seconds; per-workspace partitioning doubles as the access-control boundary |
| Concurrent WebSocket connections | fraction of 10M DAU actively online at once | low millions of persistent connections, sharded across the gateway fleet |
3. High-level design (HLD)
The HLD names the major components and the one-directional responsibility split between them - who writes, who delivers, who reads asynchronously - without yet committing to region counts or specific cluster sizes.
What each box owns
Gateway tier (stateful WebSocket layer)
Holds the long-lived WebSocket connection for every online client and registers itself in the presence registry as "this user is connected here." It never decides what to deliver on its own - it just forwards inbound sends upstream and pushes whatever the fanout layer tells it to push down to its connected clients.
Message service (single writer)
The only component allowed to create a new message. It assigns a monotonically increasing sequence number scoped to the channel, persists the message, acknowledges the sender, and only then publishes a fanout event - persistence is the point of no return, everything after it is best-effort delivery.
Presence registry (Redis)
A simple, fast map of user_id → which gateway node(s) hold that user's live connection. This is what turns "deliver to a 10,000-member channel" into "deliver to the handful of gateway nodes that actually hold connections for currently-online members," instead of a workspace-wide broadcast.
Pub/Sub fanout layer
Consumes new-message events from the message service, queries the presence registry for the channel's online members, and pushes the event to exactly those gateway nodes. Offline members are skipped entirely at this layer - they're handled by the unread/notification path instead.
Message store, Kafka, and the decoupled async pipeline
The message store is a horizontally scalable, append-friendly store partitioned by channel_id. Every persisted message is also dropped onto a Kafka topic, which two independent, replay-safe consumers read: a search indexer that feeds a per-workspace search index, and a notification/unread service that increments counters and fires push notifications for offline or inactive members. Neither consumer can ever slow down real-time delivery, because neither sits on the delivery hot path.
4. Detailed architecture diagram
The architecture diagram answers "how is this actually deployed?" - which region owns a given workspace's connections and data, how storage is sharded, and how the async pipeline is isolated from the delivery hot path.
| Decision | Choice | Reasoning |
|---|---|---|
| Workspace/connection routing | Home-region pinning, not active-active | Keeps a channel's fanout local - members of a channel are overwhelmingly in the same workspace's home region - and satisfies data-residency requirements many workspaces need. |
| Message store partition key | Hash/range of channel_id | Matches the dominant access pattern, "get this channel's messages in order," and keeps a channel's rows colocated for fast sequential range scans instead of scatter-gather. |
| Presence registry scope | Regional Redis Cluster, not global | A workspace's presence data is only ever needed by gateways and fanout within its own region, so keeping it regional avoids cross-region round trips on the delivery hot path. |
| Search indexing | Fully async via Kafka, per-workspace index shards | A slow or backlogged indexer never delays real-time delivery, and per-workspace shards double as the access-control boundary for search results. |
5. Sequence diagrams for the two critical flows
This is where an interviewer checks whether you actually understand what's synchronous versus fire-and-forget, and specifically whether the sender's acknowledgment is allowed to wait on fanout, indexing, or notifications - it shouldn't.
5.1 Send a message and its real-time fanout
Step 5 is the key ordering decision: the sender gets an acknowledgment the moment the message is durably persisted, not after fanout completes. Steps 6-8 all run after that ack and never block it. Step 7 is deliberately targeted - the fanout layer queries the presence registry for which gateway nodes currently hold connections for online channel members, and pushes only there, rather than broadcasting to every workspace member.
5.2 Client reconnect and catch-up
Notice the server holds no state about the offline client between connections - step 2 is entirely client-driven ("everything since this id, per channel"). That's deliberate: the alternative, a server-maintained live queue per offline user, would need to survive indefinitely and scale with every user who's ever gone offline, not just the ones currently online.
6. Entity-relationship (ER) diagram and schema
The data model has to answer three questions: what makes a message's position in a channel unambiguous, how does unread-count computation stay cheap at any channel size, and which table actually needs to survive hundreds of TB a year of writes.
Key modeling decisions
| Storage choice | Use when | Watch out for |
|---|---|---|
| Relational (Postgres/MySQL) | workspaces, channels, channel_members - small tables that need consistent joins for membership and permission checks, and straightforward admin queries. | Not meant to hold the messages table itself once write volume climbs into the hundreds of millions per day. |
| Wide-column / log-oriented (Cassandra/DynamoDB) | messages - extremely high write volume where the access pattern is almost entirely "get messages for this channel ordered by seq," which partitioning by channel_id serves as a fast sequential range scan. | Cross-channel queries (e.g. "all messages by this user across every channel") are expensive and need a separate index or table. |
7. Deep dives interviewers actually probe
How do you compute unread counts cheaply at any scale?
The naive approach - count rows in messages where created_at is after the user's last visit - gets slower as history grows and gets brutally expensive per channel view when there are thousands of channels across a workspace. The design above avoids counting anything: channel_members.last_read_message_id is compared against the channel's current head seq (tracked cheaply as the max seq already being written).
// unread count is a comparison, never a scan
unread_count(channel_id, user_id) =
channel_head_seq(channel_id) - channel_members.last_read_message_id
// marking as read is a single pointer update, not a bulk write
UPDATE channel_members
SET last_read_message_id = :head_seq
WHERE channel_id = :channel_id AND user_id = :user_id;
How does fanout stay affordable for a 10,000-member channel?
The fanout layer never pushes to "everyone in the channel" - it pushes to "gateway nodes with an active connection for a member of this channel," using the presence registry as a live, targeted lookup. A channel with 10,000 members but only 500 currently online keeps real-time fanout cost proportional to 500, not 10,000. Offline members don't cost anything on this path at all - they're handled entirely by the async unread/notification pipeline instead.
How do you keep search results from leaking private-channel content?
A single global full-text index would happily return a match from a private channel the searcher isn't a member of. The fix is structural, not a filter bolted on afterward: the search index is partitioned per workspace, and every query is executed against only the channels the requesting user is currently a member of (checked against channel_members at query time), so a document simply can't be returned to a user who never had read access to it.
Who decides message ordering when gateway nodes are geographically spread out?
Each channel needs exactly one source of truth for ordering. That's the message service's monotonic per-channel sequence number, assigned at persist time - never derived from client send-time or from whichever gateway node happened to receive the WebSocket frame, since those clocks can skew or arrive out of order across a wide connection fleet.
Why does reconnect ask "what did I miss" instead of the server tracking a queue?
A server-maintained live queue per offline user would need to exist for every user who has ever disconnected, potentially indefinitely, and would grow without bound across a large workspace. Making the client the source of truth for "where I left off" (last_seen_message_id per channel) keeps the server stateless between connections - catch-up is just a range query, not a queue that has to survive outages of its own.
What happens when a gateway node dies mid-connection?
The client's WebSocket drops and it reconnects to a different gateway node through the workspace-aware router; the presence registry entry for that user is updated to the new node on reconnect (with a short TTL so a crashed node's stale entries expire rather than lingering). Because catch-up is driven by last_seen_message_id rather than by anything the dead node held in memory, no in-flight state is lost - at worst, the client experiences a brief reconnect gap that the catch-up flow closes automatically.
Post a Comment
Add