Design Slack Interview Questions | JiQuest

add

#

Design Slack

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.

400MMessages / day
<1sOnline delivery target
10,000Members in one channel
Client Asends message Message serviceassign seq + persist Message storewrite, then fan out Presence registryuser → gateway node Client Bpushed in <1s Client C (offline)unread++, no push persist first, then look up who's online, then push

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

Workspaces & channelsUsers belong to one or more workspaces, and to channels within a workspace; channel membership is the unit of both delivery and access control.
Real-time deliverySending a message in a channel delivers it, in real time, to every currently-connected member of that channel.
Keyword searchMessages are searchable by keyword, scoped strictly to the workspaces/channels the searching user has access to.
Unread & notificationsEvery user has an accurate unread count per channel and is notified (in-app/push) for messages in channels they aren't actively viewing.

Non-functional requirements

Near-instant online deliveryWell under a second end-to-end for a client that is already connected.
Never lose a messageA client offline for hours or days must catch up on reconnect with zero gaps.
Search can lagA few seconds of indexing lag is acceptable - search is off the real-time critical path.
Fanout variance, one pathThe same core path must handle a 3-person DM and a 10,000-member channel without a redesign.
Explicitly out of scope Threaded-reply UI semantics, rich message-edit conflict resolution, voice/video huddles, and cross-workspace federation are called out as extensions in the deep-dive section rather than core requirements, so the core design stays focused on send, deliver, search, and unread state.

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.

MetricAssumptionResulting estimate
Messages sent (writes)10M daily active users × ~40 messages/user/day≈400M messages/day → ~4,600 msg/sec average
Peak send ratebusiness-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 onlineup to 500-10,000 real-time pushes per send - fanout, not ingest, dominates cost by 10-100×
Message storage400M 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 volumea meaningful fraction of the same message volume, partitioned per workspaceindexing lag budget of a few seconds; per-workspace partitioning doubles as the access-control boundary
Concurrent WebSocket connectionsfraction of 10M DAU actively online at oncelow millions of persistent connections, sharded across the gateway fleet
Why this matters ~4,600 msg/sec of raw writes is a workload any reasonable database handles. It's the fanout multiplier - one write turning into up to 10,000 real-time deliveries - that forces a connection-aware pub/sub layer instead of a naive "notify everyone in the workspace" broadcast.

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.

Clientweb / desktop / mobile Gateway tierstateful WS connections Presence registryuser_id → gateway node Message servicesingle writer, assigns seq id Message storewide-column, per-channel Pub/Sub fanouttargeted push, not broadcast Kafka streamdecouples async consumers Notify + unread svclast_read pointer, APNs/FCM Search indexingper-workspace, few-sec lag
Stateful / write servicesFast lookup + fanoutDurable storageDecoupled async pipeline

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.

Edge / connection layer GeoDNS / Anycast Workspace-aware routerpins workspace → home region Auth / session validate Per-connection rate limiter Region: us-east-1 (home, majority of workspaces) Gateway pods ×40 Message service ×10 Presence registry (Redis) Sticky per-user routing Region: eu-west-1 (home, EU workspaces) Gateway pods ×14 Message service ×4 Presence registry (regional)no cross-region lookupson the delivery hot path Storage tier - messages Channel shard 0-3 Channel shard 4-7 wide-column, partitioned by channel_id, ×3 replicas Metadata store (Postgres) workspaces / channels /channel_members small; consistent joins for perms Async pipeline Kafka: message-events stream consumers Search + notify OpenSearch, per-workspaceshards APNs / FCM push workers
DecisionChoiceReasoning
Workspace/connection routingHome-region pinning, not active-activeKeeps 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 keyHash/range of channel_idMatches 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 scopeRegional Redis Cluster, not globalA 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 indexingFully async via Kafka, per-workspace index shardsA 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

Client A Gateway A Message svc Msg store Fanout Gateway B Kafka 1. send message (WS) 2. forward to message service 3. assign seq id, persist (durability first) 4. ack: persisted 5. ack {message_id, seq} - does not wait on fanout 6. publish fanout event (async) 7. push to gateways with online members (targeted) 8. publish for search index + unread counters (fire-and-forget)

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

Client Gateway Message svc Msg store channel_members 1. reconnect (WS handshake + auth) 2. resume: {channel_id: last_seen_message_id} per joined channel 3. SELECT ... WHERE channel_id=? AND seq > last_seen ORDER BY seq 4. missed messages, in order 5. deliver missed messages per channel 6. read last_read_message_id per channel 7. last_read_message_id + channel head seq 8. reconciled unread counts (head - last_read)

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.

workspaces PK workspace_id BIGINT name VARCHAR plan_tier ENUM created_at TIMESTAMP channels PK channel_id BIGINT FK workspace_id BIGINT name VARCHAR is_private BOOLEAN created_at TIMESTAMP messages PK message_id BIGINT FK channel_id BIGINT FK author_id BIGINT seq BIGINT (order in channel) body TEXT created_at TIMESTAMP edited_at TIMESTAMP NULL high-volume, append-mostly channel_members PK channel_id, user_id (composite) role ENUM last_read_message_id BIGINT joined_at TIMESTAMP makes unread counts cheap 1N 1N 1N author_id / user_id reference a users table (identity/auth) intentionally left out of this diagram

Key modeling decisions

messages.seq is the real ordering key, not created_atGateway and client clocks can skew; the message service assigns one monotonic per-channel sequence number, and every client sorts/paginates on seq.
last_read_message_id makes unread counts O(1)Compare it to the channel's current head seq instead of counting unread rows; marking a channel read is a single pointer update, not a bulk write.
channel_members doubles as the ACLWhether a (channel_id, user_id) row exists is also the permission check for reading and searching that channel - no separate permissions table needed.
messages is partitioned by channel_id, not timeThe dominant query is "this channel's messages, in order" - colocating a channel's rows avoids scatter-gather across partitions on the read path.
Storage choiceUse whenWatch 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.

8. Summary: what a strong answer covers

Clarified real-time vs. eventual guaranteesJustified every capacity numberSeparated persistence from delivery Named the fanout targeting mechanismMade search and notifications fully asyncCompared SQL vs. wide-column honestly
Interview tip When asked to design Slack, the strongest signal is treating message persistence as the actual point of no return and real-time delivery as a best-effort layer on top of it: an offline client must always be able to reconstruct exact history from "last seen id" alone, regardless of whether any particular real-time push ever arrived.
No comments
Leave a Comment