Slack and Discord are rooms, not mailboxes. A Bengaluru start-up’s #incidents channel lights up at 2 a.m. and twelve engineers see each line as it is typed; a cricket server with 400,000 people online melts into one long scream during the last over of an IPL final; a college study server keeps three years of notes that anyone can scroll back through or search. Where WhatsApp delivers each message to a handful of devices and then forgets it, these products keep every message for ever, on the server, readable from any device, and deliver it live to whoever happens to be looking. That flips the design. The hard problems become fanning one message out to a million open sessions, storing trillions of messages so the newest page loads in milliseconds, and answering “what haven’t I read?” across hundreds of channels without counting anything.
Understand the problem
Both products share a shape: a tenant (a Slack workspace or a Discord server, which Discord’s code calls a guild) contains channels, and members see the channels their roles allow. Slack tenants are companies with compliance needs; Discord tenants run from five friends to communities with more than ten million members. Design for the union, and say which case each decision is for.
Functional requirements
- Users should be able to send and receive messages in a channel in real time, seeing new messages within a second while the channel is open.
- Users should be able to scroll back through a channel’s full history on any device, page by page, or jump straight to an old message.
- Users should be able to see which channels have unread messages and how many times they were mentioned, consistently across their phone and laptop.
- Users should be able to see who is online and who is typing.
- Users should be able to search the messages they are allowed to see.
Non-functional requirements
- Scale. Discord reports more than 90 million daily active users (Q4 2025). Plan for about 4 billion messages a day and 40 million connected sessions at peak (estimated below).
- Huge tenants. One server can have more than 10 million members and over a million online at once; one company workspace can have tens of thousands of people.
- Latency. p99 under 500 ms from send to screen anywhere in the world — Slack reports about 500 ms for worldwide delivery — and well under 200 ms within a region. The newest 50 messages of a channel should load with a database p99 of about 15 ms, the figure Discord reports after its ScyllaDB migration.
- Durability. Messages are kept for ever (or for the tenant’s retention policy), replicated three ways. A message acknowledged to the sender is never lost.
- Ordering. Every client shows a channel’s messages in the same order; nothing is promised across channels.
- Consistency. The sender reads their own write immediately. Unread badges and presence may lag by seconds.
- Availability and isolation. 99.99% for sending and reading; search and presence may degrade first. One giant server or enterprise must not slow down everyone else.
- Server-readable content. Unlike WhatsApp, messages are not end-to-end encrypted: the server must read them to enforce permissions, index them for search, run bots and moderate.
In the interview
Ask: “How big is the largest channel, and how many of its members are online at once?” A design for 500-person companies and one for a 1-million-online community differ exactly in the fan-out deep dive. Then: “Is history kept for ever, and is search in scope?” — that decides the storage engine. And: “Do bots and integrations count as users?” They often generate more traffic than people do.
Out of scope
- Voice and video channels. A separate media plane; see Design Zoom.
- Bots, apps and outgoing webhooks. They use the same APIs and gateway; delivery to external endpoints is a webhook problem.
- File uploads. Attachments go to blob storage behind a CDN, as in Design WhatsApp, minus the client-side encryption.
- Threads, reactions and edits. They are more events in the same channel stream; one follow-up covers threads.
- Moderation, compliance exports and billing. Important, but they read the same stores off the hot path.
The set-up
Plan the approach
Draw two paths early. The write-and-store path is plain request/response: an HTTP API that authorises, assigns an ID and persists. The real-time path pushes the stored message to every open session that may see it. Unlike a mailbox, nothing is queued per recipient; a member who is offline simply reads the channel later. Spend the deep dives on the two places this hurts: fan-out inside one enormous server, and a message store that grows by about half a petabyte a year.
Core entities
- Tenant (workspace or guild): ID, settings, roles, member count.
- Channel: ID, tenant ID, type (text, announcement, DM, thread), permission overrides, and the ID of its latest message.
- Member: (tenant, user) with roles that decide which channels they see.
- Message: a Snowflake ID, channel, author, content, attachments, mentions, and edit or delete markers.
- Read state: per (user, channel), the last message ID read and a mention count.
- Session: one connected client — session ID, user, gateway, the tenants it is actively watching, and the last sequence number sent to it.
- Presence: per user, a status (online, idle, do not disturb) and current activity, held in memory only.
A Snowflake is a 64-bit ID that sorts by time: the top 42 bits are milliseconds since the platform’s epoch, then 5 bits of worker ID, 5 of process ID and a 12-bit counter. It doubles as the timestamp, the sort key and the pagination cursor. Messages and read states in a wide-column store look like this:
CREATE TABLE messages (
channel_id bigint,
bucket int, -- 10-day window, derived from message_id
message_id bigint, -- Snowflake: sorts by time
author_id bigint,
content text,
mentions set<bigint>,
mention_everyone boolean,
PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);
CREATE TABLE read_states (
user_id bigint,
channel_id bigint,
last_read_id bigint,
mention_count int,
PRIMARY KEY (user_id, channel_id)
);
API
Sending and reading go over HTTP, where authentication, permission checks, rate limits and retries are well understood. The client’s nonce makes retries idempotent and lets it swap its grey “sending…” bubble for the real message.
POST /api/channels/9001/messages
Content-Type: application/json
{ "content": "Deploy is green", "nonce": "c8f1e2" }
{ "id": "1293018422859153408", "channel_id": "9001", "author_id": "42", "content": "Deploy is green", "nonce": "c8f1e2" }
GET /api/channels/9001/messages?limit=50 newest page
GET /api/channels/9001/messages?before=1293018422859153408&limit=50 scroll up
GET /api/channels/9001/messages?around=1170000000000000000&limit=50 jump to a message
POST /api/channels/9001/messages/1293018422859153408/ack mark read up to here
GET /api/guilds/77/messages/search?content=deploy&author_id=42 search
The gateway is a WebSocket that only pushes events. It opens with a heartbeat interval, the client identifies, and every event after that carries a per-session sequence number s so that a dropped connection can resume where it left off. Discord’s public gateway works this way:
{ "op": 10, "d": { "heartbeat_interval": 45000 } }
{ "op": 2, "d": { "token": "...", "capabilities": ["lazy_guilds"] } }
{ "op": 0, "t": "READY", "s": 1, "d": { "session_id": "a1b2", "resume_gateway_url": "wss://gw-12.example/", "guilds": ["77", "81"] } }
{ "op": 0, "t": "MESSAGE_CREATE", "s": 4182, "d": { "id": "1293018422859153408", "channel_id": "9001", "content": "Deploy is green" } }
{ "op": 6, "d": { "session_id": "a1b2", "seq": 4182 } }
The last frame is a Resume after a disconnect: “I am session a1b2 and the last event I saw was 4182”.
Back-of-envelope estimates
Show the arithmetic; the last two lines are the ones that shape the design. (Method: back-of-envelope estimation.)
Messages. Assume about 45 messages per daily user: 90M × 45 ≈ 4 billion messages a day — an assumption, but consistent with a store that has reached trillions in about a decade. 4B ÷ 86,400 ≈ 46K messages/s, about 140K/s at a 3× peak.
History reads. Assume each daily user opens 60 channels a day, each loading a page of 50: 90M × 60 = 5.4B pages ÷ 86,400 ≈ 62K page reads/s, the same order as writes. Discord measured roughly a 50/50 read/write mix back in 2017.
Storage. About 200 bytes per message with IDs and metadata, around 100 after compression, × 3 replicas = 300 B. 4B × 300 B = 1.2 TB a day, about 440 TB a year. For scale, Discord’s message cluster in 2023 was 72 nodes × 9 TB = 648 TB of disk. Copying each message into every recipient’s inbox, WhatsApp-style, with an average audience of 25 would cost 4B × 25 × 300 B = 30 TB a day. History is affordable only because it is stored once per channel.
Connections. About a third of daily users online at peak, some on two devices: 90M × ⅓ × 1.3 ≈ 40M sessions. At an assumed 500K WebSockets per gateway machine, that is 80 machines; double it for headroom and regions, about 160.
Fan-out, on average. Assume a message reaches 25 online sessions — most traffic is in small servers and DMs: 46K × 25 ≈ 1.2M deliveries/s, about 3.5M/s at peak. Typing, reactions and presence multiply the event count several times over.
Fan-out, in the largest server. One server with 1M sessions online carries 50 messages/s during a product launch. If every session received every message: 50 × 1M = 50M deliveries/s from one server, about 40 times the whole platform’s average. That one line is why this problem is rated hard.
Read states. 90M users × about 100 visible channels ≈ 9 billion read states of around 40 bytes ≈ 360 GB. Small — as long as nothing writes to them per message.
What the numbers decide:
- Store each message once, by channel, in a partitioned wide-column store — never once per recipient.
- Fan-out is the dominant cost, and it concentrates in a few huge servers: per-server owners, passive sessions and relays.
- Reads equal writes and pile onto the newest partition of hot channels: coalesce identical reads.
- Unread state must cost O(1) per message, not O(members).
Tip
The quickest way to see why chat fan-out is hard: work grows with messages × online audience, and in a busy server both grow with its size. Discord’s own example: if 1,000 online people each say one thing, that is a million deliveries; with 100,000 people it is 10 billion.
High-level design
Build one requirement at a time. By the end, the design works for servers of a few thousand people; the deep dives take it to a million.
Users should be able to send and receive messages in a channel in real time
Four components carry a message:
- API servers are stateless. They authenticate, check that the author may post in the channel, rate-limit, assign a Snowflake ID and persist.
- The message store is a wide-column database (Cassandra or ScyllaDB) reached through thin data services.
- Guild servers run one stateful process per tenant, placed on machines by consistent hashing. The process holds the member list, roles, channel permissions and the set of connected sessions, so it can work out who should receive each event.
- Gateways hold the WebSockets, one lightweight session process per connection.
- Asha’s client posts the message with a nonce. The API checks that she may post in channel 9001, applies rate limits and assigns Snowflake
1293018422859153408. - The API writes the message to the store at quorum and returns
201; Asha’s grey bubble turns solid. - The API looks up which machine owns guild 77 on the hash ring and publishes the message to that guild’s process.
- The guild process picks the sessions that are online in the guild and whose roles can view the channel, groups them by gateway, and sends one copy per gateway with the list of session IDs.
- Each gateway writes the event to each of its sessions, stamping that session’s next sequence number.
Three properties fall out. Store first, push second: the push is best-effort; a session that misses it will find the message in history. One order per server: the guild process handles events one at a time, so every session receives them in the same order, and clients sort stored messages by Snowflake ID. Offline means nothing: there is no inbox to drain. Mobile push goes out only for DMs and mentions, through a notification system. Slack draws the ownership line at the channel instead — its channel servers are placed by hashing the channel ID, at about 16 million channels per host at peak — which the first deep dive compares.
Users should be able to scroll back through a channel’s full history
All of a channel’s messages for one 10-day window share a partition, keyed (channel_id, bucket) and sorted newest first by Snowflake. Discord chose about 10 days so its busiest channels stay under roughly 100 MB per partition, and the bucket is computed from the message ID, so no lookup table is needed.
- Newest page. Read the current bucket with
LIMIT 50. If it holds fewer than 50, read the previous bucket, and so on. - Scroll up.
before=<oldest ID on screen>becomes a range scan within the right bucket. This is cursor pagination: a page never shifts when new messages arrive. - Jump to a message from a search result or a link:
around=<ID>. The ID gives the bucket directly, so the jump to a message from four years ago is one partition read. - Remember empty buckets. A channel that went quiet for a year would otherwise scan 36 empty partitions to find its last page. Discord tracks empty buckets per channel for this reason.
Users should be able to see unread channels and mentions
The sidebar answers two questions per channel: is there anything I haven’t read, and was I mentioned? Keep a read state per user per channel — the last message ID read and a mention counter — and a last_message_id on each channel.
- On every new message, update the channel’s
last_message_id— one write — and increment the mention counter only for users explicitly mentioned. - On read, the client acks the newest message it has shown. The read-state service sets
last_read_id, resets the counter and pushes the change to the user’s other sessions, so the badge on Meera’s phone clears when she reads on her laptop. - On connect, the client fetches its read states in one call and compares them with each channel’s
last_message_id.
Discord runs exactly this as a dedicated Read States service, with billions of read states. Making it cheap under @everyone is a deep dive.
Users should be able to see who is online and who is typing
Presence and typing are transient events: they are never written to the message store, and losing one is harmless. The trick is to send each client only what is on its screen.
Slack describes the same rule: a client receives presence only for the users visible in its window, and moving presence to this subscription model cut the presence events each client received by a factor of five. Typing indicators expire on the client after a few seconds unless refreshed, so a lost “stopped typing” event costs nothing.
Users should be able to search the messages they can see
Search is asynchronous. Each new message is put on a queue; index workers write it in bulk to an Elasticsearch index that holds one server’s messages; a search API looks up which index holds the server, runs the query, gets back message IDs, fetches the messages from the store and drops any from channels the user cannot see. Indexing a few seconds behind is fine, because people search for what was said last month, not a second ago. The deep dive covers what changes at trillions of documents.
You now have a working platform for servers of a few thousand people. Everything below is about the outliers: the million-member server, the hottest partition, the reconnect storm and the enterprise that outgrows a shard.
Deep dives
How do we fan out to a server with a million people online?
A guild process handles one event at a time. Every message costs work in proportion to the online audience, and a bigger server also sends more messages, so the work grows with the square of its size. One process that is comfortable at 20,000 online is hopeless at a million: its queue grows faster than it drains, messages arrive seconds late, and eventually it runs out of memory.
Bad: one owner process doing all the fan-out. Simple, and it gives one order for every event in the server. It stops scaling somewhere in the tens of thousands of online users.
Good: own channels instead of servers. Slack hashes each channel ID to a channel server, so one company’s channels spread across the whole fleet, and a replacement channel server is ready in under 20 seconds. You gain: no single process per tenant, and a busy #general never shares a thread with a quiet #random. You pay: anything that needs a whole-server view — member lists, a role change that alters which channels people see, voice state — has to be gathered from many hosts. And a single channel with a million people online still has one owner.
Great: keep one owner per server, make it do less, and delegate the fan-out. This is what Discord did to carry Midjourney’s server, with more than 10 million members and over a million online:
- Passive sessions. Most members of a big server are not looking at it. Treat their sessions as passive: no message bodies, only what the sidebar needs, until the user clicks in. In Discord’s large servers about 90% of user-to-server connections turned out to be passive, cutting fan-out work by about 90%. Because work grows with the square of the size, a 10× saving buys only about √10 ≈ 3× more members — so it is necessary but not enough.
- Relays. Put relay processes between the guild and the sessions. Each relay holds up to 15,000 sessions and does their permission checks and sends; the guild sends one copy per relay. A million sessions need 1,000,000 ÷ 15,000 ≈ 67 relays. The guild keeps what needs a full view (membership, roles, voice); each relay receives only the members its sessions need — when every relay held the full list, creating one meant copying tens of millions of members and stalled the guild for tens of seconds.
- Lazy member lists. Never send 10 million members to a client. Send the visible window of the list and updates for that window only, the same subscription idea as presence.
- Slow work off the main loop. An
@everyonepermission scan or a hand-off to another machine during a deploy runs in a worker that reads a shared in-memory table, so the guild keeps routing messages meanwhile.
Run the numbers from the estimates. Fifty messages a second to a million sessions was 50M deliveries a second. With 90% passive, about 100,000 active sessions remain: 50 × 100,000 = 5M deliveries/s, spread over 67 relays ≈ 75,000 deliveries/s per relay, which one machine handles. The guild process itself sends 50 × 67 ≈ 3,350 copies a second.
| One process per server | One owner per channel | Per server + relays + passive | |
|---|---|---|---|
| Ordering | One order for the whole server | One order per channel | One order per server |
| Whole-server views (members, roles, voice) | Local and cheap | Gathered from many hosts | Kept in the guild process |
| Biggest server it handles | Tens of thousands online | Bounded by the busiest channel | Over a million online |
| Cost | Simple | Many hosts per tenant | Relay hand-offs, more moving parts |
| Used by | Small deployments | Slack’s channel servers | Discord’s largest guilds |
How do we store trillions of messages and keep hot channels fast?
Bad: one relational table indexed on (channel, created_at). Discord began on a single MongoDB replica set; at about 100 million messages, in late 2015, the data and index no longer fit in RAM and latency became unpredictable. Timestamps are also a poor key, because two messages can share one.
Good: a wide-column store, partitioned by (channel, bucket) and clustered by Snowflake. In Cassandra a channel’s 10-day window sits together on disk, already sorted; a page is one range read and a send is an append. Discord measured sub-millisecond writes and reads under 5 ms in 2017. Two traps come with it. Deletes are writes too — tombstones — so a channel whose millions of messages had been deleted took 20 seconds to load while the database skipped over them. And writing an empty column also writes a tombstone, so write only the fields that are set.
By 2022 the cluster had grown to 177 nodes holding trillions of messages, and its worst enemy was the hot partition. An @everyone announcement in a huge server sends hundreds of thousands of people to read the same few rows within seconds. The node holding that partition falls behind; because reads and writes use quorum, every query that touches the node slows down; compactions lag and garbage-collection pauses add more.
Great: data services in front of the database, routed by channel, and a better engine.
- Request coalescing. A thin data service sits between the API and the database, one endpoint per query and no business logic. If many requests ask for the same rows at once, the first starts a worker task that queries the database, and the rest subscribe to its result.
- Routing by channel. Each request carries a routing key — the channel ID — and is consistently hashed to a data-service instance, so every request for a channel meets in one process, where coalescing can work.
- A shard-per-core engine without a garbage collector. Discord moved the messages from 177 Cassandra nodes to 72 ScyllaDB nodes with 9 TB of disk each (up from about 4 TB). p99 for fetching history went from 40–125 ms to 15 ms, and p99 for inserts from 5–70 ms to 5 ms. The migration ran at up to 3.2 million messages a second.
The general pattern is in hot keys and cache stampedes: when one key gets most of the traffic, make sure the expensive work for it happens once.
How do sessions survive disconnects, deploys and reconnect storms?
A laptop lid closes, a train enters a tunnel, a gateway is redeployed. Each time, the client must catch up on what it missed without reloading everything — and when millions of clients drop at once, their return must not knock the system over.
Bad: reconnect means start again. The client identifies afresh and downloads its full state: every server, channel, role and read state. For someone in 100 servers that can be megabytes, and after a regional blip millions of clients ask for it in the same minute.
Good: heartbeats plus a full re-sync. The gateway’s Hello frame sets a heartbeat interval — 45 seconds in Discord’s documented example. A client that stops getting heartbeat acks treats the connection as a zombie and reconnects at once, instead of waiting for TCP to notice. Recovery is still a full reload.
Great: resumable sessions. The session outlives its socket for a few minutes and keeps buffering events. The client remembers its session ID, the resume URL and the last sequence number it processed; on reconnect it sends Resume instead of Identify, and the server replays only what it missed. If the client comes back too late, the server answers Invalid Session and the client identifies from scratch.
Resume handles single drops. Reconnect storms — a gateway deploy, an office network flapping, a region failing over — need more:
- Jitter everything. Discord’s documented gateway has each client wait the heartbeat interval multiplied by a random factor between 0 and 1 before its first heartbeat, so clients that reconnected together do not stay in lock-step. Reconnect delays get the same treatment (backoff with jitter).
- Drain gateways slowly. Move sessions off a gateway over minutes, not seconds, and send a “reconnect” hint so clients resume on another gateway.
- Rate-limit identifies. Full identifies are the expensive kind, so they get their own limit; Discord’s bots, for example, may only start a fixed number of sessions every five seconds.
- Make a fresh start cheap. Slack’s Flannel edge cache answers start-up from the nearest edge, sends a slimmed-down snapshot and loads the rest lazily; the start-up payload shrank 7× for a 1,500-person team and 44× for a 32,000-person team. Because clients of one team are hashed to the same Flannel host, a whole office reconnecting is served from cache rather than from the core.
How do we keep unread counts and mentions cheap?
Every sidebar shows unread state for every channel, and every message can change it. Get the data model wrong and one message costs millions of writes.
Bad: count on demand. On start-up, run “messages after my last read” for each of your 100 channels. That is 100 partition reads per user, multiplied by every reconnecting client — and counting through a channel with 2 million new messages is slow anyway.
Bad, in a different way: a counter per member, bumped per message. One post in the announcements channel of a 10-million-member server becomes 10 million counter writes. It is WhatsApp’s per-recipient inbox again, this time for a number.
Good: compare pointers, count only mentions. As in the high-level design: one last_message_id write per message, and counters only for people explicitly mentioned. The sidebar shows bold for unread and a number only for mentions; an exact “N new messages” is computed only for the channel you open, by reading it.
Great: mentions of everyone computed lazily, behind a cache-first service.
@everyoneand role mentions are not fanned out. Storemention_everyoneon the message, and keep per channel a short list of the IDs of messages that mention everyone or a role. A user’s badge is their own counter plus the number of those IDs after theirlast_read_id, worked out when the sidebar loads. Nothing is written per member.- Hot read states live in memory. Discord’s Read States service keeps an LRU cache per node — millions of users and tens of millions of read states — and writes to Cassandra when an entry is evicted, or 30 seconds after an update. Hundreds of thousands of cache updates a second become tens of thousands of database writes a second.
- One ack covers everything before it, and each ack is pushed to the user’s other sessions so badges agree across devices within a second.
In the interview
The sentence to say: “I never write per member when a message is sent. Unread is a comparison of two IDs, mentions are counters only for the people named, and @everyone is a flag counted at read time. So a post to ten million people costs one write.”
How do we stop one huge tenant from hurting everyone else?
Tenants differ by five orders of magnitude, from five friends to an enterprise of tens of thousands. The data layout decides whether the biggest one is a hot spot.
Bad: shard everything by tenant. Slack’s original MySQL layout put all of a workspace’s data — messages, channels, DMs — on one shard, with thousands of workspaces per shard and a lookup table from workspace to shard. You gain a lot: every query for a workspace touches one database, and new customers just get new shards. You pay as customers grow. The biggest tenants outgrew the biggest hardware; a few shards ran hot while most of the fleet sat over-provisioned; and when one shard failed, every workspace on it had a complete outage. Features that cross tenants, such as shared channels between two companies, broke the “one tenant, one shard” rule altogether.
Good: shard each table by the key that grows with its load. Messages by channel ID, users by user ID. The largest tenant’s channels then spread across every shard, and a failed shard takes out a slice of channels across many tenants rather than all of one. You pay with a routing layer, and with tenant-wide queries that now touch many shards.
Great: a sharding layer that can split hot shards online, plus bulkheads for the giants. Slack moved to Vitess, which keeps MySQL but routes queries and splits shards without application changes. By 2020 it served 2.3 million queries a second at peak — 2 million reads, 300,000 writes — with a median of 2 ms and a p99 of 11 ms. When query rates rose 50% in one week in March 2020, Slack split one of its busiest keyspaces horizontally. On top of that, give the very largest tenants their own resources — dedicated relays for the fan-out, a dedicated search cell — and per-tenant and per-bot rate limits, so a noisy neighbour degrades only itself.
How do we search years of messages across a whole server?
Bad: scan the message store. A LIKE over years of partitions is a full scan per query; nothing about a store partitioned by channel and time helps with “who said deploy last spring?”.
Good: an Elasticsearch index per server, built lazily and in bulk. Most servers are never searched, so index a server only when someone first searches it — recent messages first, so results appear quickly, then the deep history at lower priority. New messages flow through a queue to workers that bulk-index them, a few seconds behind. Keep only searchable fields and IDs in the index, not message bodies; results are hydrated from the message store, which you read anyway to show surrounding context. Check permissions twice — filter by the channels the user can see in the query, then again on the hydrated messages. See Elasticsearch.
Great: what changes at trillions of documents. Discord’s 2025 rebuild lists the failure modes:
- The queue must not drop work. A Redis-backed queue started dropping messages when an Elasticsearch outage built a backlog; a durable pub/sub queue turns an outage into a delay instead of data loss.
- Batch by destination. A bulk request of 50 messages bound for 50 different nodes fails if any one node is down. With 100 nodes and one failed, the chance a batch touches it is 1 − (99/100)⁵⁰ ≈ 40%. Group each batch by its target index, so one bad node fails only its own batches.
- Many small clusters, in cells. Clusters of 200+ nodes had masters running out of memory and could not be upgraded safely; dozens of small clusters, each index kept to about 200 million messages or 50 GB, can be.
- Special-case the giants. Lucene caps one index at about 2 billion documents, so the biggest servers move — by dual-writing and re-indexing in the background — to dedicated indexes with several shards.
- Shard by the query. Searching all of a user’s DMs is impossible on an index sharded by server, so DMs are indexed a second time, sharded by user.
The payoff Discord reports: trillions of messages indexed, twice the indexing throughput, median query latency from 500 ms to under 100 ms and p99 from 1 s to under 500 ms, on 40 clusters.
The final design
Put it together. Writes go through the API to the store and then out through guild servers; reads go through data services; everything transient stays in memory; search and push hang off the side.
- Connect. A client opens a WebSocket to the nearest gateway and identifies, or resumes with its session ID and last sequence number. Its start-up snapshot is slim, and its sessions in big servers start passive.
- Send. The client posts the message with a nonce. The API checks permissions and rate limits and assigns a Snowflake ID.
- Store. Data services, routed by channel and coalescing identical reads, write the message to its
(channel, bucket)partition in ScyllaDB. History pages come back the same way, by ID range. - Publish. The API hands the message to the guild server that owns the tenant on the hash ring.
- Fan out. The guild server — through relays for the largest servers — sends it to the gateways of active sessions allowed to see the channel. Each gateway stamps a per-session sequence number and keeps a replay buffer for resumes.
- Presence and typing. Transient events, held in memory and sent only to subscribers: the people on screen, the viewers of a channel.
- Read states. Each message updates the channel’s last message ID and the counters of people explicitly mentioned. Acks move
last_read_idand are synced to the user’s other sessions. - Search. New messages are indexed asynchronously, in bulk, into Elasticsearch cells. The search API queries one small index and fills in results from the message store.
- Push. Offline users are notified only of DMs and mentions.
Check it against the requirements. A message is stored once, so a year costs about 440 TB rather than an inbox per reader. Fan-out costs messages × active sessions, split across relays, so the largest server needs about 75,000 deliveries a second per relay instead of 50 million from one process. Reads of hot channels are coalesced, and ScyllaDB keeps history fetches near 15 ms at p99. Unread state costs one write per message. Resumable sessions turn most disconnects into a short replay, and sharding by channel spreads the biggest tenant across the fleet. Gateways sit in regions close to their users, which is how Slack keeps worldwide delivery around 500 ms.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | Channels, history, real-time | Asks about the largest channel, history retention and search early | Contrasts room semantics with a mailbox, and sizes the design by its outliers |
| Fan-out | Pub/sub to channel members | Fan-out only to online sessions with permission checks; owner per server or channel | Quadratic growth, passive sessions, relays, lazy member lists |
| Storage | A messages table | Partition by (channel, bucket), Snowflake IDs, cursor pages | Hot partitions, request coalescing, tombstones, engine and migration trade-offs |
| Sessions | “Reconnect on failure” | Heartbeats and resume with sequence numbers | Replay buffers, identify limits, jitter, cheap start-up through edge caches |
| Unread state | Count unread rows | Read-state pointer per user per channel | Lazy @everyone, cache-first read states, cross-device sync |
| Presence | Broadcast to everyone | Only to people who can see you | Subscription by screen, coalescing, presence as a degradable feature |
| Tenancy | One database | Sharding by tenant | Sharding by channel, online resharding, bulkheads for the largest tenants |
| Search | “Add Elasticsearch” | Async bulk indexing per server | Lazy indexing, batching by destination, cells, the Lucene document cap |
Follow-up questions
What if we add threads?
A thread is a channel with a parent: its own ID, its own (thread_id, bucket) partitions and its own read states. The parent channel stores a small summary on the root message — reply count, last reply ID, a few participants — updated with each reply. Fan out a reply only to the thread’s members and to sessions currently viewing it, not to the whole channel; the parent channel gets one cheap “thread updated” event. This keeps a 5,000-reply argument from flooding everyone else in #general.
What if a user is in 200 servers?
Do not load 200 servers at start-up. The ready payload lists server IDs and unread markers; the full state of a server — channels, roles, member list window — arrives when the user opens it. All 200 sessions start passive, so the user’s connection receives message bodies only for the server on screen plus DMs and mentions. It is the same lazy-loading idea that let Slack shrink its start-up payload 44× for a 32,000-person team.
What if messages are edited or deleted?
An edit writes the new content to the same row and fans out a MESSAGE_UPDATE; a delete fans out MESSAGE_DELETE and removes the row, which in Cassandra-style stores leaves a tombstone. Mass deletion is the dangerous case: millions of tombstones in one partition make every page read skip them. Keep tombstone lifetimes short with regular repairs, remember which buckets are empty, and send the delete to the search index too — through the same durable queue, or a deleted message will keep turning up in search results.
What if a company needs data residency and retention policies?
Make the tenant’s home region part of its routing: its message keyspace, search cell and backups live in that region, and the API routes its requests there. Slack runs Vitess clusters in six regions for its data-residency product. Retention is a background job per tenant that deletes partitions older than the policy, plus legal holds that pause it; because data is bucketed by time, dropping a year of messages is a range of partitions, not a scan. Gateways and real-time fan-out can stay global, since they hold nothing at rest.
What if we need end-to-end encryption, like WhatsApp?
Then the server can no longer read messages, and most of this design goes. Search must move to the client, history for a new device has to come from the user’s other devices, bots and moderation lose access to content, and permission checks become key management: removing someone from a channel means rotating the channel’s key. For big groups you would use a group key-agreement protocol such as MLS rather than pairwise sessions. That is the trade WhatsApp makes on purpose; for a workplace or a public community, server-side history is usually the product.
How to say it in the interview
“I’ll design a Slack- or Discord-style platform: tenants with channels, history kept for ever, about 4 billion messages a day, and some servers with a million people online.
The key difference from WhatsApp is that this is a room, not a mailbox. I store each message once, by channel, and push it only to sessions that are online and allowed to see it. Offline members get nothing pushed; they read history when they open the channel.
Sending is an HTTP call. The API checks permissions, assigns a Snowflake ID — time-ordered, so it doubles as the sort key and the page cursor — and writes to a wide-column store partitioned by channel and a ten-day bucket. Data services in front coalesce identical reads, routed by channel, so an @everyone announcement causes one database read, not ten thousand.
Then the API publishes to the process that owns that server on a consistent hash ring. It knows who is online and their roles, and fans out through gateways. Fan-out grows with the square of a server’s size, so for the huge ones I make most sessions passive until the user clicks in and split the sending across relays of about fifteen thousand sessions — a million online becomes 67 relays.
Gateways give each session sequence numbers and a short replay buffer, so a dropped connection resumes instead of reloading; reconnects are jittered and start-up data is loaded lazily, which tames reconnect storms.
Unread is a read state per user per channel: the last-read ID compared with the channel’s last message ID, plus mention counters for people actually named; @everyone is counted at read time. Presence and typing are transient and only sent to people who can see them. Data is sharded by channel rather than by tenant, so the biggest customer is spread out, and search is a lazily built Elasticsearch index per server, fed asynchronously.”
Concepts used in this design
- Real-time connections: long polling, SSE and WebSockets — the gateway’s persistent connection.
- Publish/subscribe — channels, presence and typing as subscriptions.
- Consistent hashing — placing guild, channel and data-service owners.
- Cassandra and DynamoDB — partition and clustering keys for the message store.
- Design a unique ID generator — Snowflake IDs that sort by time.
- Pagination: offset vs cursor — scrollback with
beforeandaround. - Hot keys, cache stampede and invalidation — coalescing reads of one hot partition.
- Database sharding — choosing a shard key per table, and resharding online.
- Noisy neighbours and fairness — keeping one huge tenant from hurting the rest.
- Heartbeats — finding zombie connections.
- Timeouts, retries and backoff with jitter — spreading out reconnect storms.
- Elasticsearch — the per-server search index.
- Design a notification system — push for DMs and mentions.
- Design WhatsApp — the mailbox-style sibling, with end-to-end encryption.
References and further reading
- Maxjourney: Pushing Discord’s Limits with a Million+ Online Users in a Single Server (Discord, 2023)discord.comOne Elixir process per guild, quadratic fan-out, passive sessions (about 90% in large servers), relays of up to 15,000 sessions, and a server with 10M+ members and 1M+ online.
- How Discord Stores Trillions of Messages (Discord, 2023)discord.comPartitioning by (channel, time bucket), hot partitions, Rust data services with request coalescing and channel-hashed routing, and 177 Cassandra nodes replaced by 72 ScyllaDB nodes with p99 reads of 15 ms.
- How Discord Stores Billions of Messages (Discord, 2017)discord.comWhy channel_id plus a Snowflake message_id, 10-day buckets to keep partitions under 100 MB, a 50/50 read/write ratio, and the tombstone trap.
- Why Discord is switching from Go to Rust (Discord, 2020)discord.comThe Read States service: one read state per user per channel, mention counters, an LRU cache backed by Cassandra, and commits 30 seconds after an update.
- How Discord Indexes Trillions of Messages (Discord, 2025)discord.comLazy, bulk indexing into Elasticsearch sharded by guild; PubSub instead of Redis; batching by destination; cells of small clusters; DMs sharded by user; special handling for guilds beyond Lucene’s ~2B document limit.
- Discord Developer Documentation: Gatewaydiscord.comHello and heartbeat_interval with jitter, sequence numbers, Resume with session_id and resume_gateway_url, Invalid Session, and identify rate limits.
- Discord: company factsdiscord.com90M+ daily active users as of Q4 2025.
- Real-time Messaging (Slack Engineering, 2023)slack.engineeringChannel servers on a consistent hash ring (about 16M channels per host at peak), regional gateway servers, presence only for users on screen, and worldwide delivery in about 500 ms.
- Scaling Datastores at Slack with Vitess (Slack Engineering, 2020)slack.engineeringWhy sharding by workspace created hot spots and shared outages, the move to sharding messages by channel, and 2.3M queries/s at peak with an 11 ms p99.
- Flannel: An Application-Level Edge Cache to Make Slack Scale (Slack Engineering)slack.engineeringLazy loading instead of a full snapshot at startup, an edge cache that absorbs reconnect storms, and 5× fewer presence events after moving to pub/sub.