SYSTEM DESIGN · DESIGN PROBLEMS · CHAT AND REAL TIME · MEDIUM · 7 OF 41

Design Slack: Channels, Threads, Unread Counts and Search for Workplaces

A LADDER walkthrough of a workplace chat system: requirements, scale math for a billion messages a day and thirty million sockets, the API and a data model sharded by workspace, then the deep dives that make it different from WhatsApp: channel subscriptions on gateway servers, unread counts and mentions across devices, threads, permission-aware search, notifications and reconnect catch-up.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 19

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Listen. Companies get a workspace with public and private channels and direct messages. Users send, edit and delete messages, and reply in threads. Each channel shows unread counts and mention badges. Users search all history they have access to. Everything works across desktop, web and phone at once. Non-functional: messages appear within a couple of hundred milliseconds, and history is durable and kept.

Scope out calls, the bots platform and billing. Then ask about the extremes, because they set the hard limits. The largest workspace has two hundred thousand members, and the largest channel, an all-company announcements channel, has fifty thousand. Retention is forever unless an admin sets a limit. Messages are encrypted at rest but not end to end, because the server must index them for search.

Requirements
  • functional:
  • workspaces with channels
  • (public, private) and DMs
  • send, edit, delete messages
  • threads with replies
  • unread counts and @mentions
  • search history, files
  • works on several devices
  • non-functional:
  • delivered in ~200 ms
  • history kept, never lost
Scope and questions
  • out of scope: calls, huddles,
  • apps and bots platform,
  • billing, compliance exports
  • ask: biggest workspace?
  • → 200k members
  • ask: biggest channel?
  • → 50k members (#general)
  • ask: retention? → forever
  • unless the admin sets less
  • ask: encryption? → at rest

Ask scale

Ask about scale. Twenty million daily users sending fifty messages a day is a billion messages a day, about ten thousand a second on average. Workplace traffic is concentrated in office hours, so assume a peak of four times: forty thousand a second. If the average message lands in a channel with thirty members, that is over a million deliveries a second at peak.

Storage. At about a kilobyte per message with formatting and metadata, a billion messages is a terabyte a day, around three hundred sixty-five terabytes a year, and it is kept forever, so it must be sharded from day one. The search index is about the same size again. Sockets: twenty million users on one and a half devices each is thirty million connections; at three hundred thousand per gateway, about a hundred gateway servers.

Traffic
20M daily active users
× 50 messages sent per day
= 1B messages/day
  / 10^5 s ≈ 10,000 msgs/s
  work-hour peak ×4
  ≈ 40,000 msgs/s

reads: each message delivered
  to ~30 members on average
  → ~1.2M deliveries/s at peak
Storage and sockets
message ≈ 1 KB (rich text,
  metadata, reactions)
1B × 1 KB = 1 TB/day
  ≈ 365 TB/year, kept forever
  × replicas

search index ≈ size of text

sockets: 20M users × 1.5
  devices ≈ 30M
  / 300k per gateway ≈ 100

Define API

Define the API. Writes go over HTTP: post a message with a client ID for dedupe and an optional thread parent, update, delete, page history, mark a channel read up to a timestamp, and search. A boot call returns the user's channels and unread counts in one response. Then the client opens a WebSocket that streams events: new messages, edits, read-state changes and typing indicators.

API
POST /chat.post
  { channel, text, client_id,
    thread_ts? }
POST /chat.update, /chat.delete
GET  /history?channel&before
POST /read { channel, ts }
GET  /search?q=...
GET  /boot → channels, unreads

WebSocket events:
  message, edit, read, typing

Data model

The data model. Messages keyed by channel and timestamp, with the user, text, optional thread parent, a denormalised reply count and the client ID. Channel membership. And read state: for each user and channel, the timestamp they have read up to and their mention count. A relational database sharded by workspace works well: workspaces are natural tenants, and queries almost never cross them.

Data model
messages
  PK (channel_id, ts)
  workspace_id, user_id, text,
  thread_ts, reply_count,
  client_id, edited_at
members (channel_id, user_id)
read_state
  PK (user_id, channel_id)
  last_read_ts, mentions

shard key: workspace_id

High-level design

Draw the high-level design, starting with opening the app. One boot call returns the user's channel list and unread badges, assembled from membership and read state. Then the client opens a WebSocket to a gateway server. The gateway learns which channels this user belongs to and makes sure it is subscribed to each of them in pub-sub. Messages themselves load lazily when a channel is opened.

Now sending. The client posts to the message service, which checks membership, inserts the message on the workspace's shard, and publishes an event to the channel's pub-sub topic. Every gateway with at least one member of that channel connected is subscribed, so each receives the event once and pushes it to its local members' sockets, including the sender's other devices.

Deep dive: sharding

Deep dive one: sharding. Sharding by workspace keeps every query on one shard and isolates customers from each other. The problem is the biggest customers: a company with two hundred thousand members can outgrow any single shard. So large workspaces are sharded by channel instead. A routing table maps each workspace, or each channel for big ones, to its shard, which lets you move hot tenants without downtime.

Sharding
  • by workspace_id:
  • + all queries stay on one
  • shard, easy isolation
  • - one 200k-member company
  • can outgrow a shard
  • large workspaces: shard by
  • channel_id instead
  • routing table: workspace or
  • channel → shard
  • move tenants online

Deep dive: gateways

Deep dive two: gateway subscriptions. A user might be in three hundred channels, and a gateway holds three hundred thousand users, so it subscribes to the union of their channels, which can be tens of thousands of topics. Keep a reference count per channel: subscribe when the first member connects, unsubscribe when the last leaves. Routing users of the same workspace to the same gateways shrinks that union dramatically.

Channel subscriptions
  • user in ~300 channels
  • gateway holds 300k users
  • → union of their channels,
  • often tens of thousands
  • ref count per channel:
  • subscribe on first member,
  • drop on last
  • group users of a workspace
  • on the same gateways
  • → much smaller union

Deep dive: unreads

Deep dive three: unread counts. Never store a read flag per message per user; that would be billions of rows. Store one cursor per user and channel: the timestamp read up to. A channel is unread if its latest message is newer than your cursor. Mention counts are incremented when you are mentioned and reset on read. When you read on your laptop, a read event goes to your other devices, so your phone's badge clears too.

Unread and mentions
  • never a row per message per
  • user (30M × history)
  • cursor: last_read_ts per
  • (user, channel)
  • unread = channel.latest_ts
  • > last_read_ts (bold)
  • mention count: incremented on
  • @you, reset on read
  • read on laptop → event to
  • your phone: badge clears

Deep dive: threads

Deep dive four: threads. A reply is just a message with a thread timestamp pointing at its parent, stored in the same channel partition, so loading a thread is one range query. The parent row keeps a denormalised reply count and latest reply time, so the channel view can show the reply summary without counting. People who wrote, replied or were mentioned follow the thread and see it in their threads view.

Threads
reply = message with
  thread_ts = parent ts
stored in the same channel
  partition
parent row: reply_count,
  latest_reply_ts
  (updated on each reply)
thread followers: author,
  repliers, mentioned users
→ "new replies" view per user

Deep dive: search

Deep dive five: search. An indexer reads the database change log and writes new and edited messages into a search engine like Elasticsearch, with an index per workspace, or per group of small workspaces. A few seconds of indexing lag is fine. Permissions are the subtle part: every query is filtered to channels the user is a member of right now, so leaving a private channel removes access to its messages immediately.

Search with permissions
  • change log → indexer →
  • Elasticsearch, index per
  • workspace (or per group of
  • small ones)
  • lag: a few seconds is fine
  • query: text match AND
  • channel_id IN (user’s
  • channels)
  • leaving a private channel
  • removes access immediately

Deep dive: notifications

Deep dive six: notifications. A notification service consumes channel events and decides who to alert: direct messages, mentions, keywords, thread replies. The rules matter more than the plumbing. Do not push to a phone if the user is active on their laptop. Respect do-not-disturb and channel settings. Wait about a minute and cancel the push if the message was read in the meantime. See the Notification system problem.

Notification rules
  • trigger: DM, @mention,
  • keyword, thread reply
  • suppress if active on any
  • device in the last ~30 s
  • respect do-not-disturb and
  • per-channel settings
  • delay ~1 min; cancel if read
  • one push per message, not
  • per device

Deep dive: catch-up

Deep dive seven: ordering and catch-up. Each channel's shard assigns a unique server timestamp, which orders messages within the channel. Edits, deletes and reactions are events keyed by message ID that clients apply in place. After a laptop sleeps for an hour, the client does not fetch history for three hundred channels; it calls boot again to learn which channels changed, and fetches a channel's messages only when it is opened.

Ordering, edits, catch-up
  • order: server ts per channel
  • (one writer per channel
  • shard, unique ts)
  • edits, deletes, reactions:
  • events keyed by message id
  • client_id dedupes retries
  • reconnect after 5 min:
  • boot again: which channels
  • changed? fetch those lazily
  • (not 300 history calls)

Trade-offs

The trade-offs. Sharding by workspace gives isolation and simple queries, but the largest tenants need channel-level sharding. Search is a few seconds behind. Cursor-based unreads are cheap, but exact counts need a count query, so show ten plus instead of precise big numbers. Monitor send-to-display latency, subscriptions per gateway, hot shards, index lag and push latency. At ten times, add gateways and shards; nothing is global.

Trade-offs
  • workspace sharding: isolation,
  • but giant tenants need
  • channel sharding
  • search lag: seconds
  • cursor unreads: cheap, but
  • exact counts need a count
  • query (show "10+")
  • monitor: send-to-display p99,
  • gateway subs, shard hotness,
  • index lag, push latency
  • 10x: more gateways, shards

Interview summary

The summary. Clients write over HTTP and receive events over one WebSocket per device. Messages are keyed by channel and timestamp in a database sharded by workspace, with big tenants sharded by channel. Each message is published once to its channel topic, and gateways subscribe on behalf of their connected users. Unreads are a cursor per user and channel, synced across devices. Threads are parent pointers. Search is an asynchronous index filtered by membership.

Interview summary
  • writes over HTTP, events over
  • a WebSocket per device
  • messages (channel, ts) in a
  • DB sharded by workspace
  • publish per channel; gateways
  • subscribe for their users
  • unreads: cursor per user and
  • channel, synced to devices
  • threads: parent pointer
  • search: async index + ACLs

Interview questions

How does Slack differ from WhatsApp in design?

History is stored server-side forever and must be searchable, channels can have tens of thousands of members, users have several devices whose read state must agree, and data is partitioned by customer workspace.

How would you shard the message data?

By workspace ID so a tenant’s queries stay on one shard and customers are isolated, with a routing table that lets the largest workspaces be sharded by channel instead and lets tenants move between shards.

How do messages reach all members of a large channel?

The message service publishes once to the channel’s pub-sub topic; every gateway with at least one connected member is subscribed and pushes the event to its local sockets, so fan-out is per gateway, not per member.

How do you compute unread counts efficiently?

Store one last-read timestamp per user and channel instead of per-message flags; a channel is unread when its latest message is newer, mention counts are kept incrementally, and read events sync to the user’s other devices.

How does search respect private channels?

Messages are indexed asynchronously per workspace, and every query is filtered to the channel IDs the user currently belongs to, so access follows membership changes immediately.

How would you avoid sending a push notification when the user is at their desk?

Track activity across devices and suppress pushes if the user was active anywhere recently, delay non-urgent pushes briefly and cancel them if the message is read, and respect do-not-disturb and channel preferences.

How do clients catch up after being offline?

They call the boot endpoint again to get channel latest timestamps and unread state, then fetch history only for channels that changed and are opened, rather than replaying every channel.