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.
- 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
- 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.
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
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.
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.
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.
- 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.
- 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.
- 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.
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.
- 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.
- 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.
- 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.
- 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.
- 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