"Design Slack" sounds like a chat question, and candidates lose points by answering it as one. Designing a generic chat app is about 1:1 conversations; designing WhatsApp adds mobile delivery semantics and end-to-end encryption. Slack is a different product category: a workspace where an entire organisation talks in long-lived channels, and where the interesting engineering sits in channel fan-out, per-user read state, threading, and searching years of accumulated history.
Everything below is a reasonable design an interviewer would expect, not a description of Slack's actual internals. Where an approach is publicly known in the industry, it is called out as such.
+-----------+ WebSocket +-------------------+
| Desktop | ============> | Connection Tier |
| / Web | | (subscriptions) |
+-----------+ +---------+---------+
|
+----------+----------+
| Channel Service |
| membership + order |
+--+-------+--------+-+
| | |
+----------------+ | +----------------+
v v v
+-------------------+ +--------------------+ +--------------------+
| Message Store | | Read-State Store | | Index Pipeline |
| channel log, seq | | cursors + mentions | | -> search cluster |
+-------------------+ +--------------------+ +--------------------+
^
+--------+--------+
| Presence + Apps |
| webhooks, bots |
+-----------------+
1. Clarify the requirements
Functional requirements
- Workspaces (and enterprise organisations containing many workspaces), each with its own users, channels, and data boundary
- Public channels anyone in the workspace can join, private channels restricted to invited members, and direct messages
- Real-time message delivery to everyone currently viewing a channel
- Threaded replies hanging off a parent message
- @-mentions, per-user unread counts, and badge state per channel
- Full-text search across message history, plus file upload and sharing
- Presence, and integrations: incoming webhooks, bots, and slash commands
Non-functional requirements
- Sub-second delivery for members with the channel open
- Strict tenant isolation — no data ever crosses a workspace boundary
- Durable history: messages are retained for years and must remain searchable
- Read-heavy: far more channel loads and searches than sends
Back-of-the-envelope scale. Take 20M daily active users each sending ~50 messages a day: about 1B messages/day, ~12K writes/sec average and maybe 5× that at the European and US morning peaks. Reads dominate — channel loads, scrollback, and search easily produce 20–50× the write volume. At ~500 bytes per message that is ~500 GB/day of text before files, so history storage is the long-term cost driver and files go to blob storage.
2. API and protocol
Channel history, search, and administration are ordinary HTTPS requests. Only live delivery runs over a socket, and the socket carries subscriptions rather than raw user-to-user routing.
# HTTP
POST /v1/channels/{cid}/messages { text, threadParentId?, clientMsgId }
GET /v1/channels/{cid}/history ?before=seq&limit=50
POST /v1/channels/{cid}/read { lastSeenSeq }
GET /v1/search ?q=deploy+rollback&in=cid&from=uid
# WebSocket events
-> SUBSCRIBE { channelIds: [...] }
<- MESSAGE { cid, seq, userId, text, threadParentId? }
<- UNREAD { cid, unreadCount, mentionCount }
<- PRESENCE { userId, state: "active" | "away" }
3. High-level architecture
Clients hold a persistent connection to a connection tier whose job is subscription management: which sockets care about which channels. A session registry in Redis maps userId → connectionId → gateway, and a reverse map gives channelId → connected members — the single most important structure in the design, because it is what turns a 10,000-member channel into a manageable push.
A send goes to the channel service, which authorises the write against membership, assigns a per-channel sequence number, appends to the message store, and emits an event. The connection tier pushes that event to subscribed sockets, the read-state service updates unread counters, and the indexing pipeline makes the message searchable a moment later.
4. The channel fan-out problem
Someone posts in #engineering with 10,000 members. The naive design writes 10,000 rows — one per member's inbox — and does not survive a workspace where several such channels are busy at once.
What works is write once, read many: append the message a single time to the channel's ordered log, then split delivery in two.
- Push to members who are connected and subscribed to that channel right now — typically a few hundred of the 10,000, because most are looking at something else.
- Pull for everyone else: they fetch from the log at their cursor when they next open the channel. Nothing was written on their behalf in the meantime.
This is the read-heavy fan-out choice, the mirror image of fan-out-on-write for social feeds, and it works here precisely because channel membership is bounded by an organisation rather than a follower graph. The counterpart problem is the @channel storm: an @-mention to all 10,000 members must still update 10,000 badge states. Handle that asynchronously through a queue, batching counters and pushing them to connected clients as they are computed, rather than synchronously on the sender's request.
5. Unread counts and read state
Unread state is per user per channel, which makes it the largest-cardinality data in the system — users × channels, not messages.
Model it as a cursor: for each (userId, channelId) store lastSeenSeq plus a small mentionCount. The unread badge is latestSeq - lastSeenSeq, computable without scanning messages because the channel already tracks its latest sequence number. Mentions are counted separately, since a badge for a direct @-mention carries more weight than a generic unread dot and users expect it to be exact.
The cursor table lives in a fast key-value store keyed by user, so the client's boot request — every channel and its unread state — is a single partition read. When the user reads a channel the client sends a mark-read event; the write is idempotent (cursors only move forward) so replays are harmless.
6. Threads and the message data model
Threading falls out of one nullable column. Every message row carries an optional thread_parent_id: NULL means a top-level channel message, non-null means a reply.
messages— msg_id, channel_id, seq, user_id, text, thread_parent_id, created_at, edited_atchannels— channel_id, workspace_id, name, visibility, latest_seqmemberships— (channel_id, user_id), joined_at, roleread_state— (user_id, channel_id), last_seen_seq, mention_count
The channel view queries only thread_parent_id IS NULL, so replies never clutter the main timeline, and the parent row denormalises reply_count and last_reply_at so the thread summary renders without touching replies at all. Partition the message store by channel_id: history stays co-located, ordering within a partition is trivial, and workspace isolation follows from the key prefix.
7. Connection management at scale
Millions of desktop clients hold open sockets, and each is subscribed to a slice of its workspace's channels. Three details matter:
- Subscribe to what is visible, not to everything. A user in 60 channels does not need live frames for all 60. Subscribe to the open channel and any channel with a pending mention; the rest reconcile on switch.
- Reconnection is a resync, not a replay of pushes. On reconnect the client sends its per-channel cursors and asks for the delta. Missed messages come from the log, so the connection tier never has to buffer per-user backlogs.
- Stagger reconnects. A gateway restart would otherwise stampede every client at once; jittered exponential backoff is the standard defence.
8. Search and integrations
Search cannot run against the primary store — the access pattern is the opposite of the write path. Message writes are published to a stream that an indexing pipeline consumes, building an inverted index in a search cluster partitioned by workspace. Every query is filtered by the requester's channel memberships before results return, so a private channel can never leak. Indexing is asynchronous, so a message is searchable a second or two after it is sent — an acceptable trade, worth naming as one.
Files go to blob storage; only metadata and extracted text enter the index. Integrations — incoming webhooks, bots, slash commands — post through the same authorised write path as a human message, with per-app rate limits and a scoped token, so the rest of the pipeline stays uniform.
Key trade-offs the interviewer probes
- Fan-out on read vs on write. Shared channel log plus per-user cursors, not per-member inboxes: cheap writes, a little more work at read time, and unread state stays O(channels) rather than O(messages).
- Consistency. Ordering within a channel must be strict; unread counts, presence, and search freshness are safely eventual.
- Tenant isolation vs efficiency. Partitioning by workspace makes isolation and per-customer retention simple, at the cost of uneven shards when one tenant is enormous — split the largest onto dedicated shards.
- History retention. Years of messages dominate storage; tier cold history to cheaper storage while keeping the index hot.
Framework reminder: every system design answer follows the same arc — requirements → estimates → API → high-level design → data model → scale → trade-offs. Keep the system design cheat sheet in mind and narrate which stage you're in.
Practice collaboration-scale design with live AI support
CoPilot Interview surfaces a structured design skeleton — requirements, API, data model, and scaling — in about 4 seconds during real Zoom and Teams calls. Free for Windows and macOS, with a private desktop window.
Try it freeFAQ
How is designing Slack different from designing WhatsApp or a generic chat app?
A generic chat app is mostly about 1:1 conversations, and WhatsApp adds mobile delivery and end-to-end encryption. Slack is a workspace product: everything is scoped to an organisation, conversation happens in long-lived public and private channels rather than pairwise threads, and the hard problems are channel fan-out at team scale, per-user unread state, threading, and searching years of history. Interviewers expect you to name that difference early.
How do you fan out a message to a 10,000-member channel?
Write the message once to the channel's log, then push it only to members who currently have an open connection and are subscribed to that channel, which is usually a small fraction of the membership. Everyone else picks the message up on their next fetch. Writing one copy per member into 10,000 inboxes is the naive answer and it does not scale, so a shared channel log plus a per-user cursor is the design an interviewer is looking for.
How do you model unread counts per user per channel?
Store a read cursor for each user and channel: the sequence number of the last message that user has seen. The unread count is then the number of messages in the channel with a higher sequence number, which can be computed on read or kept as a cached counter. Mentions are tracked separately because a badge for an at-mention matters more than a generic unread, and both are updated when the client sends a mark-read event.
How should threads be modelled in the data layer?
Give every message an optional parent identifier. A message with no parent is a top-level channel message; a message with a parent is a reply belonging to that parent's thread. The parent row keeps a reply count and last-reply timestamp so the channel view can render the thread summary without loading replies, and replies are fetched only when the user opens the thread.
How does search work across years of message history?
Message writes are published to a stream that an indexing pipeline consumes, building an inverted index in a search cluster partitioned by workspace. Queries hit that index rather than the primary message store, and every result is filtered by the channels the requesting user is actually a member of, so a private channel never leaks into someone else's results. Indexing is asynchronous, so a new message becomes searchable a moment after it is sent.