Design Message Storage at Scale
- Design Discord's message store: trillions of messages, fast history reads, and very uneven channels
- Explain the `((channel_id, bucket), message_id)` key and why Snowflake ids make history a range scan
- Handle the pushback: hot partitions, the 500,000-reader celebrity post, pagination, edits and deletes
Understanding the problem
Design the database that stores and serves Discord's messages. Every message ever sent, across tens of millions of channels, has to be kept durably and read back fast. Open a channel and the last fifty messages load instantly; scroll up and older ones page in. It has to hold trillions of messages and absorb tens of thousands of new ones a second. It also has to stay healthy when one channel, a giant server going viral, is a thousand times busier than a quiet group chat.
Warm into the shape before reaching for a database. A message store sounds like the easy half of a chat product, the part you get for free once fan-out works. It is the opposite. Delivery moves a message once and forgets it. Storage keeps it forever, and every person who opens a channel, scrolls its history, or jumps to a search result reads it back. So the volume that matters is the read rate against a corpus that only ever grows. The entire design comes down to one question. How do you lay trillions of messages out on disk so that fetching the last fifty in one channel is cheap? And how do you make sure one runaway channel never drags the rest of the cluster down with it?
((channel_id, bucket), message_id) out loud does real work.Functional requirements
Keep the core small and let the interviewer widen it. The chain is short and read-heavy. Someone sends a message into a channel and it has to be durably stored, in order. Someone opens that channel and the most recent messages load. Someone scrolls up and older pages come back, walking backward through time. And because messages are not truly immutable, an author can edit or delete one, and the store has to reflect that on the next read. Two requirements are worth naming on purpose. Ordering, because messages in a channel have to come back in the sequence they were sent, so the storage layout has to encode time. And history reads dominating writes, because a message is written once but read by everyone who ever opens or scrolls the channel.
In scope
- Durably store every message sent into a channel, ordered, at tens of thousands of writes a second
- Read the most recent N messages in a channel instantly when it is opened
- Paginate backward through a channel's full history as the reader scrolls up
- Reflect edits and deletes on the next read without rewriting the whole channel
- Stay healthy when one channel is orders of magnitude busier than the rest (the hot partition)
Below the line (name it, then skip)
- Real-time delivery and the WebSocket gateway (that is its own design; storage is the durable record underneath it)
- Full-text search ranking and relevance (we keep messages searchable via a separate index, but the query engine is a neighboring system)
- Attachment and media bytes (images and files live in object storage plus a CDN; we store only the reference)
- Permissions and who can read a channel (an upstream authorization layer we assume answered yes)
- Message encryption at rest key management (assumed handled by the storage platform)
Non-functional requirements
Reason each target from what a message store is for, because the data model falls out of them. Durability comes first. A sent message must never be silently lost, so writes replicate before they are acknowledged. Read latency is the target you feel. Opening a channel should return its recent messages in low tens of milliseconds, because that read sits directly in front of a person waiting to see the conversation. Name the load skew early, because reads outnumber writes heavily, and in this store reads are the more expensive operation. So the layout has to make the common read, the last N messages in one channel, land in a single partition rather than scatter across the cluster.
Then the relaxation worth banking now and cashing in later. Message reads tolerate eventual consistency of a beat or two. If a brand-new message takes a moment to appear on a cold history fetch it self-heals, because the real-time gateway already pushed it to connected clients. The store does not have to be the instant source of truth for the live view. That is what lets it lean on a highly available replicated design rather than a strictly consistent one. Availability is non-negotiable, because a store that is down means nobody can read any history anywhere, so the design favors staying up and converging over blocking a read for perfect freshness.
Sizing the message mountain
Put the numbers down, because the partitioning scheme lives or dies on them. Take the message rate first. Discord runs on the order of billions of messages a day. Call it four billion for the arithmetic. Spread evenly that is about four billion divided by eighty-six thousand seconds, roughly forty-five thousand writes a second on average. Traffic concentrates in the busy evening hours and spikes when a huge server goes off, so peak runs several times higher, into the low hundreds of thousands of writes a second. That is the write floor the store has to absorb without flinching.
Now the part that makes this Discord-shaped: the total is enormous and it only grows. Four billion a day across roughly a decade is on the order of fourteen trillion messages. That is why Discord's own numbers moved from billions in 2017 to trillions by 2022. At a conservative kilobyte per row with metadata, trillions of messages is petabytes on disk, so this was never going to fit on one machine and the question becomes how to shard it. The last piece is the read pattern. Reads outnumber writes by something like an order of magnitude, and almost every read is the same shape: the most recent messages in one specific channel, then older pages as the reader scrolls up. That single dominant access pattern, recent-first within one channel, is the thing the primary key has to serve cheaply.
- Messages per day: ~billions. Call it ~4B for the math, ~45k writes/s average
- Total stored: trillions. Petabytes on disk; grew from billions (2017) to trillions (2022)
- Peak write QPS: low 100k/s. Evening concentration plus viral-server spikes over the average
- Read pattern: recent-first. Reads dominate ~10:1; last N in one channel, then page backward
One relationship in that grid sets the whole architecture. The corpus is petabytes and unbounded, but the hot working set, the messages people read, is the recent tail of each active channel. So the design's real job is to keep each channel's recent messages physically together and cheap to fetch, while the cold trillions sit undisturbed until someone scrolls back to them. Get the partition key right and the common read touches one place; get it wrong and every channel open scatters reads across the cluster. That choice is the first deep dive, and everything before it is just earning the right to make it.
The set-up
Core entities
Name the objects before drawing. Three carry the design, and the one worth fixing early is the message id, because it is not a random uuid. It is a Snowflake, a 64-bit id whose high bits are a timestamp, which means ids sort in the same order as time. That single property is what turns "fetch the newest messages" into an ordered range scan instead of a sort, and it is why the id is also the clustering key inside a partition.
Message
One utterance from an author into a channel: the author id, the channel id, the content, edit and delete flags, and a Snowflake message_id. This is the row every read returns and the unit the whole store is sized around. Content is small, on the order of a kilobyte with metadata; attachments are references, not bytes.
Channel
The container a message belongs to: a server text channel, a group DM, or a direct thread. It is the natural unit of access, because every read is scoped to one channel, which is exactly why the channel id is the first half of the partition key. Channels vary wildly in traffic, from a three-person DM to a server with hundreds of thousands of members.
Bucket
A static time window layered on top of the channel, roughly ten days wide, that bounds how much of a channel lands in a single partition. Derivable from a message's timestamp because the Snowflake id carries one. It is the second half of the partition key and the mechanism that stops a busy channel from growing one partition without limit.
Then state the storage choice and the key plainly, because interviewers ask both. Messages go in a wide-column store, the Cassandra-and-then-ScyllaDB family. The workload is enormous, write-heavy at ingest, and read as ordered ranges within a partition, and a partitioned, clustered wide-column layout is built for exactly that. The primary key is ((channel_id, bucket), message_id). The partition key is the pair (channel_id, bucket), so all of one channel's messages for one ten-day window sit together on the same replicas. The clustering key is message_id, so within that partition the messages are already stored in time order. Recent-first history is then a bounded, ordered scan of one partition, no cross-node fan-out and no sort.
API
Keep the external surface thin. Writing a message appends a row, reading history is a bounded backward scan anchored on a message id, and edits and deletes are targeted updates on a single row. Reference these by name in the walkthrough and the deep dives.
Two calls carry the weight. GET /channels/{channelId}/messages is the hot path, and its before cursor is doing something specific. It anchors the scan on a message id the client already holds, so paging backward means "give me the fifty messages with ids just below this one." That is an ordered range read inside the partition, not an offset the database has to count to. When before crosses the start of a bucket, the read walks into the previous bucket, which is the one seam pagination has to handle. POST /channels/{channelId}/messages looks trivial but sets everything up: it mints the Snowflake id, and because the id carries a timestamp, the bucket is computed from the id rather than stored or looked up.
High-level design
Build it one requirement at a time and name each bottleneck as it surfaces. The write path is thin and the read path is where the interesting work lives. In front of the database sits a data-services layer, a role Discord fills with a purpose-built Rust service. Its job is to protect the store from the read pattern. It routes every request for a given channel to the same place and collapses duplicate concurrent reads into one. Behind it is the wide-column store itself, holding the messages, with a separate search index fed asynchronously off to the side so full-text queries never touch the hot path. The diagram codes each box by role: blue for clients, violet for our services, green for the data stores. The walkthrough tags the same components.
1) Write a message. A message author posts to the message API through POST /channels/{channelId}/messages. The API mints a Snowflake message_id, and because the id's high bits are a timestamp, it computes the ten-day bucket straight from the id rather than storing or looking it up. It hands the write to the Rust data services layer, which routes it to the instance that owns this channel and appends one row to the wide-column store under the partition key (channel_id, bucket). Writes replicate across a few nodes before the ack, so a message is durable the moment the author sees it land. The bottleneck being punted is what happens when this channel is the one that is a thousand times busier than the rest, which the partitioning deep dive takes up.
2) Load recent history. A history reader opens the channel and calls GET /channels/{channelId}/messages?limit=50. The message API forwards it to the Rust data services layer, which routes by channel to the owning instance and asks the wide-column store for the top of the current partition. Because the messages are clustered by message_id and Snowflakes sort by time, the newest fifty are the first fifty rows of the partition, an ordered range read with no sort and no cross-node fan-out. The reader sees the conversation in low tens of milliseconds. The hard part deferred here is the celebrity case: 500,000 people open the same channel in the same second, and every one of them wants the same rows. That is the coalescing deep dive.
3) Page backward through history. As the reader scrolls up, the client re-calls the same endpoint with before set to the oldest message_id it currently holds. The Rust data services layer turns that into "the fifty rows with ids just below this cursor," another bounded ordered scan of the same partition. When the cursor crosses the start of the ten-day bucket, the read steps into the previous bucket and continues. History becomes a walk across a small sequence of partitions instead of one unbounded scan. The seam being punted is exactly that bucket boundary, and how a sparse channel that spans empty buckets avoids a slow crawl, which the pagination deep dive resolves.
4) Edit, delete, and search. An author edits or deletes through PATCH or DELETE on a single message id. The Rust data services layer turns that into a targeted update on one row. The id and its position in the partition never move, so ordering is untouched and only that row's content or delete flag changes. Separately, every write also flows asynchronously into the search index, an inverted index that answers keyword queries off the hot path, so a full-text search over history never competes with channel reads. Two threads run off this stage: how mutation coexists with an append-mostly, replicated store, and why edits and deletes have to be idempotent. Those close out the deep dives.
Technology choices
None of these boxes is a specific product yet; each is a slot you fill in the room. Put a concrete tool in every slot, defend the category behind it, and know the failure mode, because the pressure lands on how the pick degrades under the exact skew this system has.
- The message store is a wide-column database, Cassandra originally and ScyllaDB today, partitioned by
(channel_id, bucket)and clustered bymessage_id. This category fits because the workload is a massive, ever-growing set of small rows read as ordered ranges within a partition. A partitioned-and-clustered layout serves that cheaply, and it scales by adding nodes instead of sharding a relational database by hand. The failure mode to name is the hot partition: one blazing channel-bucket concentrates reads on a few replicas and drags tail latency across the whole cluster, because reads cost more than writes here. Discord moved from Cassandra to ScyllaDB, a C++ rewrite that is shard-per-core with no garbage collector. The fleet shrank from 177 nodes to 72, and historical-read p99 fell from tens of milliseconds to around 15, largely because Cassandra's JVM GC pauses were gone. - The Rust data-services layer is a stateful intermediary tier that does consistent-hash routing plus request coalescing. A dedicated tier in front of the database fits because of the read pattern. Many clients want the same recent rows of the same channel at once, a thundering-herd problem the database should never see. It routes all requests for a channel to one instance so that a burst of identical reads becomes a single in-flight database query whose result fans back to every waiting caller. The failure follow-up is an instance dying with in-flight work. Routing is by consistent hash, so the channel rehashes to another instance and coalescing resumes. The store behind it is the durable record, so nothing is lost; reads are only briefly slower.
- The message API is a stateless service pool behind a load balancer, minting Snowflake ids on write. Stateless is right because each request is self-contained once the id is minted, so you add workers as traffic climbs and any node can serve any request. The subtle correctness point is id generation. Snowflake ids must be monotonic and unique across all API nodes, so each node carries a worker id and the ids embed a timestamp plus a per-node sequence. That keeps them sorting by time without a central coordinator. The failure mode is clock skew across nodes; bounding it and never letting the sequence wrap within a millisecond keeps ordering intact.
- The search index is a separate inverted index, something like Elasticsearch, fed asynchronously from the message write stream. Keeping search out of the primary store is right because full-text ranking is a different access pattern that would pollute the row cache and steal IO from channel reads. State the freshness tradeoff plainly. The index lags the store by the length of the async pipeline, so a just-sent message may be searchable a second or two late. That relaxation is fine, because live reading already comes from the gateway and the store. In the failure mode, the indexer falls behind under a write burst. Users see staler search results while message reads stay fast and succeed, and that is where you want the pain to land.
One contrast is worth naming, because it is a common probe. The wide-column store and the search index both hold every message, so why not one system. They are different animals. The store is the durable, ordered record optimized for one access pattern, recent-first within a channel, and it must never lose a write. The index is a derived, best-effort view optimized for a completely different pattern, keyword lookup across everything, and it is allowed to lag and to be rebuilt from the store at any time. Collapsing them means one workload's queries starve the other's, which is why they stay separate. Down the whole list, name the brand so the interviewer knows you have run one, then defend the category, because the category is what the follow-up questions test.
Potential deep dives
The rest of the score is made here. The interviewer picks a component and pushes. These four are where this prompt leans, and the first two are the ones a Discord loop almost always reaches.
1) How do you pick a partition key that survives a giant channel?
This is the decision the whole design rests on. In a wide-column store the partition key decides which rows live together on the same replicas, and it decides which reads are cheap. Pick it wrong and either your common read scatters across the cluster, or one channel grows a single partition without bound until it becomes the thing that breaks. The tension is real. You want a channel's messages together for cheap history reads, but a server with hundreds of thousands of members can send orders of magnitude more messages than a quiet DM. "A channel" as the unit is both the right grain and a landmine.
Approach
Give every message a globally unique id and partition the table by that id, spreading writes evenly across the whole cluster. Writes are beautifully balanced, no single node runs hot on ingest, and the table is trivially simple.
Challenges
It optimizes the wrong operation. The common read is "the last fifty messages in this channel," but with messages scattered by id across every partition, that read has to hit every node, collect candidates, and sort them by time. You have turned the hottest query in the system into a cluster-wide scatter-gather, which is exactly what a wide-column store is worst at. Balancing writes is easy; the whole point is to make the dominant read cheap, and this makes it as expensive as possible.
Approach
Make the partition key the channel id, so all of a channel's messages live together on the same replicas, and cluster them by Snowflake message_id so they are stored in time order. Now "the last fifty messages in this channel" is a bounded, ordered range read of one partition: no fan-out, no sort. This matches the access pattern precisely and is the obvious right first instinct.
Challenges
It has no ceiling on partition size. A quiet DM stays tiny, but a channel in a giant server accumulates messages forever, and its single partition grows without bound. Wide-column stores get unhappy past roughly 100 MB per partition: compaction slows, repairs drag, and reads against that partition develop long tails. So the very channels that matter most, the busiest ones, are the ones whose partition becomes a chronic hot spot. Partitioning by channel is directionally correct but incomplete until you bound how big one channel's partition can get.
Approach
Make the partition key the pair (channel_id, bucket), where the bucket is a static time window, roughly ten days wide. A channel's messages are now split into per-window partitions, each bounded in size no matter how long the channel lives. A single window's messages still sit together in time order for a cheap range read. The bucket is derivable from a message's Snowflake timestamp, so writes compute it with no lookup, and a history read knows which bucket a cursor falls in. Ten days is the sizing lever: it was chosen so that typical channels keep each partition well under the 100 MB comfort zone. Reading the recent tail touches one bucket; scrolling back walks into the previous one.
Challenges
The bucket bounds size but does not eliminate the hot partition for a truly enormous channel. At roughly a kilobyte a row, 100 MB is about 100,000 messages. So a channel doing more than about 10,000 messages a day can still overflow a ten-day bucket. While it is the active window, that channel-bucket is a genuine hot spot, hammered by both writes and reads. Time-bucketing does not make that go away; it bounds the blast radius to one window and buys the read-side protection to survive it, which is the next deep dive. Sparse channels have the opposite cost: a channel that spans many near-empty buckets makes deep pagination walk several partitions to gather one page, which the pagination deep dive handles.
((channel_id, bucket), message_id) key is the most quotable fact about how Discord stores messages. Naming it, with the ten-day window and the sub-100-MB reason, separates a prepared answer from a generic one. Discord's engineering write-ups say reads cost more than writes in this store. A busy channel-bucket becomes a hot partition that hurts latency across the whole cluster, and that is why the bucket exists. An interviewer may ask how your scheme handles "a server with a small group of friends versus a server with hundreds of thousands of people." They want to hear that both are the same shape, channel plus time bucket. The second one is exactly the hot partition your read-side layer is built to absorb. Framing the bucket as a deliberate size bound rather than an arbitrary detail is the tell that you understood the real system.2) A celebrity posts and 500,000 people load the same channel at once. What breaks?
This is the failure the bucket alone cannot fix. Time-bucketing bounds how big a hot partition gets, but it does nothing about a sudden read stampede against it. Picture a huge announcement channel: someone posts, a notification goes out, and half a million clients open that one channel in the same few seconds. Every one of them wants the same recent rows of the same partition. Naively that is 500,000 near-identical database reads landing on the handful of replicas that own the partition. Reads are the expensive operation here, so those replicas saturate and latency climbs for everyone, well beyond that channel. The question is how to keep one viral channel from turning into a cluster-wide brownout.
Approach
Trust the database. Add read replicas for the partition and let all 500,000 requests through, spreading them across more copies so no single replica takes the full load. It needs no new component and leans on the store's own replication.
Challenges
Replicas move the ceiling a little and cost a lot, and they do not touch the actual waste, which is doing the identical read half a million times. Every one of those requests is asking for the same rows at the same instant, so you are paying full query cost for work whose answer is byte-for-byte identical across nearly all callers. Under a real spike the replicas still saturate, tail latency still bleeds into unrelated channels sharing those nodes, and you have spent hardware to brute-force a problem that is really about deduplication, not capacity.
Approach
Put a cache, something like Redis, in front of the store keyed by channel and page. The first reader misses and loads from the database; everyone after reads the recent page straight from the cache until a new message invalidates it. For a hot channel with a stable recent tail this collapses the vast majority of reads into cache hits and takes the stampede off the store.
Challenges
A shared cache helps once the key is warm, but it leaves the thundering-herd gap at the exact worst moment. When a popular channel's page is cold or just got invalidated by a new message, all 500,000 requests miss simultaneously and stampede the database at once, because nothing has serialized them. You have optimized the steady state and left the spike, which for a channel that posts and then gets hammered is the whole event. You need something that collapses the concurrent misses, not just the repeat hits.
Approach
Put the Rust data-services layer between the API and the store, and give it two jobs. First, consistent-hash routing: every request for a given channel goes to the same service instance, which concentrates all the demand for one hot partition in one place where it can be managed. Second, request coalescing. When many requests for the same rows arrive while a database query is already in flight, the instance issues one query and attaches every waiting caller to that result. Then it fans the answer back to all of them. Now a 500,000-way stampede for one channel becomes effectively one database read whose result is shared, and a short-lived cache of that result absorbs the trailing requests. The database sees a trickle where it would have seen a flood.
Challenges
Routing all of one channel to one instance recreates a hot spot at the service layer, so that instance has to be fast and cheap per request. That is a large part of why Discord wrote this tier in Rust rather than a GC language: predictable microsecond-level latency with no pauses. You also take on a coordination cost, tracking in-flight queries and their waiters, and you have to bound it so a slow database query does not pile up unbounded waiters. A coalesced read also means every caller gets the same snapshot. A message sent during the in-flight window appears on the next read instead of instantly, which is fine because the gateway already delivered it live. The payoff is that the store's load is decoupled from how viral a channel goes.
The caching patterns lesson generalizes read-through caching and the thundering-herd problem that coalescing solves.
3) How does backward pagination work across bucket boundaries?
History reads are the dominant traffic, and scrolling up is the part that looks simple until the buckets get in the way. Within one ten-day bucket, paging back is clean: anchor on the oldest message id the client holds and read the fifty rows with ids just below it. The trouble starts at the edges. A cursor eventually reaches the start of its bucket, and the next older message lives in a different partition. Worse, a channel that has been quiet for months has empty buckets in between, so a naive "read the previous bucket" can return nothing and force another hop. The question is how to keep pagination a bounded number of cheap reads rather than an open-ended crawl.
Approach
Expose pagination as an offset and limit, "give me messages 500 through 550 of this channel," and let the store skip and return. It is the familiar paging API and easy for a client to reason about with a page number.
Challenges
Offsets force the database to count. To return rows 500 through 550 it has to walk past the first 500, so deep scrolls get linearly slower the further back you go. In a partitioned store the offset means nothing across partitions anyway. It also breaks under concurrent writes: a message inserted while the user reads shifts every offset, so the reader sees a duplicate or skips one at the page boundary. Offsets are the wrong primitive for an append-heavy, time-ordered log.
Approach
Page with a before cursor set to a message id, not an offset. Each request reads the fifty rows with ids just below the cursor, an ordered range scan the clustered layout serves directly, and returns the new oldest id as the next cursor. Because Snowflake ids are stable and time-ordered, a message inserted concurrently never shifts the cursor's meaning, so paging is stable under writes and cheap regardless of depth within the partition.
Challenges
It is correct inside one bucket but silent about the boundary. When the cursor reaches the start of the current ten-day partition, a plain range read returns fewer than fifty rows or none. The client can't tell whether the channel is exhausted or the history continues in an older bucket. Without handling the hop explicitly, a reader hits an artificial floor at the bucket edge even though older messages exist.
Approach
Keep the id cursor and make the data-services layer bucket-aware. When a page comes back short at the start of a bucket, the layer computes the previous bucket from the cursor timestamp and continues the scan there. It stitches the results into one page of fifty, so the client never sees the seam. A sparse channel can have long gaps of empty buckets. Keep a small per-channel index of which buckets contain messages, so the walk jumps straight to the next non-empty bucket instead of probing each ten-day window in turn. The client keeps sending the same opaque cursor; the server decides how many buckets it takes to fill a page.
Challenges
You now carry a little more state, the per-channel bucket index, plus the logic to span partitions inside one request. A single page read can touch two or three partitions when it lands on a boundary. That is a bounded cost, a handful of ordered reads rather than a full scan, and it only bites at the edges. The bucket index has to stay in step with writes. It updates when a channel writes into a new bucket for the first time, a cheap conditional write on the write path. The alternative, letting the client discover empty buckets by repeated calls, pushes the crawl onto the network and feels broken to the user.
4) How do edits and deletes live in an append-mostly store?
A message store is append-heavy but not append-only, because people edit and delete. That has to coexist with a design tuned for ordered appends and replicated for availability. The shape helps here. A message is one row identified by (channel_id, bucket, message_id), so an edit or a delete is a targeted update on exactly that row. The id never changes, so the message keeps its position and ordering is untouched. The interesting questions are what a delete does to a partition, and how mutation behaves when the store is eventually consistent and clients retry.
Approach
Treat an edit as a single-row update to the content plus an edited timestamp, and a delete as setting a deleted marker on the row rather than physically removing it. Reads filter out or render deleted rows, history stays contiguous, and ids never move, so ordering and pagination cursors keep working. Soft-deleting also means a moderation action or an accidental delete has a record, and the physical space is reclaimed later by the store's normal compaction.
Challenges
In a store like Cassandra a delete writes a tombstone, a marker that shadows the row until compaction removes it. A channel that deletes heavily can pile up tombstones that make reads scan past dead rows, hurting the latency you protected everywhere else. So soft-delete is right but has to be paired with awareness of tombstone buildup, tuning compaction and grace periods so a spammy delete-heavy channel does not slow its own reads. This is a real operational edge, not a free lunch.
Approach
Make edits and deletes idempotent so retries under an eventually consistent, highly available store are always safe. Key each mutation on the message id and the operation, and carry the edit timestamp. Applying the same delete twice is then a no-op, and a resent edit lands the same content in the same place. Because replicas converge with last-write-wins on a per-cell basis, stamp mutations with a monotonic timestamp so a delete cannot be silently overwritten by a slow, stale edit that arrives after it. The client can safely retry a mutation that timed out, and however many copies of the request arrive, the row settles to one deterministic state.
Challenges
Last-write-wins convergence means you must get the ordering source right. If two mutations race with clocks that disagree, the wrong one can win, so mutations lean on the message's own timeline and server-assigned timestamps instead of client clocks. There is also a small consistency window where one replica shows the edited message and another still shows the old content. That is acceptable for message content, because it heals in a beat and the live view came from the gateway anyway. The line to hold is that durability and convergence matter more than instantaneous global agreement for this surface.
What is expected at each level
That is a lot of surface, and how much you have to land depends on the level the loop is calibrated for. Treat the inferred rungs below as directional: a mid engineer, Senior as the independent bar, and a Lead-and-beyond scope above it. The design round carries more weight the more senior the role.
Mid-level engineer
Breadth leads here, roughly 80/20. Reach a working end-to-end shape: messages land in a wide-column store partitioned by channel, recent history is an ordered read of one partition, and the API pages backward with a message-id cursor. The non-negotiable is choosing the partition key deliberately and explaining why "the last fifty messages in a channel" should be one cheap read rather than a scatter-gather. You do not have to arrive at time-bucketing or coalescing unprompted, but you should recognize that a giant channel is a problem when the interviewer raises it. The bar: a coherent skeleton you can defend, with the interviewer smoothing the rough edges.
Senior engineer
Depth enters, roughly 60/40, and this is the primary independent bar. Reach a great tier on your own somewhere: the (channel_id, bucket) partition key with the sub-100-MB sizing reason, or the data-services layer that routes by channel and coalesces the celebrity-post stampede. You should raise the hot-partition failure mode and the read-heavy skew before being asked, and defend why reads cost more than writes in this store rather than treating it as symmetric. The bar: a near-complete design where you found the hot partition and the thundering herd yourself and had a concrete answer for each.
Lead and beyond
Depth leads across several components as scope widens, roughly 40/60. Expect to drive the coalescing layer end to end, including why it is written for predictable low latency with no GC pauses and how it degrades when an owning instance dies. Reason about pagination across bucket boundaries and sparse channels, edits and deletes as idempotent mutations under eventual consistency with last-write-wins, and the operational reality of tombstones and compaction. Treat the ScyllaDB migration as an example of choosing a tool to remove a specific failure, GC pauses on the hot path, rather than as trivia. You lead, the interviewer steers rather than rescues. The bar: you own the partition-plus-coalescing model and keep durability, read latency, and mutation correctness as separate, defensible decisions through every refinement.
Key Takeaways
- ✓The whole design turns on one key:
((channel_id, bucket), message_id). Partition by channel plus a ten-day time window, so a channel's history sits together and stays under about 100 MB. Cluster by a time-sortable Snowflake id, so recent-first history is a bounded ordered scan of one partition with no fan-out and no sort. - ✓Storage is read-dominated, and in a wide-column store reads cost more than writes, so the hot partition is the main threat: a giant channel concentrates reads on a few replicas. Time-bucketing bounds its size, and a data-services layer that routes by channel and coalesces concurrent reads collapses a 500,000-way celebrity stampede into a single database query.
- ✓Pagination is a message-id cursor, never an offset, so it stays cheap and stable under concurrent writes; the server walks bucket boundaries and skips empty windows so the client never sees the seam. Edits and deletes are idempotent single-row mutations that keep the id and its position, with soft-delete plus attention to tombstone buildup.
- ✓The Cassandra-to-ScyllaDB move (177 to 72 nodes, p99 into the teens) shows the model was already right and the win was removing JVM GC pauses from the hot path. Naming the real data model, the hot partition, and coalescing is what makes a message-storage answer read as Discord rather than generic NoSQL.
Discord interview guideKnow what Discord asks before you walk in.
28 coding · 6 system design · 19 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.