Chat feels like a beginner project. A messages table with a channel id, an author, some text, and a timestamp. Insert on send, select on read. It genuinely works, right up until the message table gets very large, and then the database that served you so well becomes the thing that wakes you up at night. Discord lived this, and how they got out of it is one of the clearer real world storage lessons around.
The core problem is volume and access pattern together. Messages accumulate forever and are never really deleted in bulk, so the table only grows. Meanwhile the read pattern is specific and relentless: give me the most recent messages in this channel, and let me scroll back from there. A traditional relational database can do this, but as the table grows into the range where it no longer fits comfortably in memory and the indexes get huge, the pain shows up as unpredictable latency. Some reads are fast, some hit cold data on disk and stall, and maintenance operations start to hurt. The system was not wrong, it was just the wrong shape for data that grows without bound and is always queried by channel and time.
The key decision was to move messages to a wide column store built for exactly this pattern, in Discord's case Cassandra and later a compatible successor. The important part is not the brand, it is the data model, because a store like this rewards you for modeling around your query and punishes you for fighting it. The query is "recent messages in a channel," so the design makes channel the thing that groups data together and time the thing that orders it within that group. Concretely, the partition key decides which physical bucket a row lives in, and the clustering key decides the order inside that bucket. You want messages for one channel to sit together and to be stored already sorted by time, so a "give me the latest messages here" read is a single ordered scan of one bucket rather than a hunt across the whole dataset.
There is a subtle trap in that model and it is worth calling out, because it is the kind of thing that separates a textbook answer from a real one. If you make the partition simply the channel id, then a channel that has been active for years accumulates an unbounded number of messages in a single bucket, and that bucket becomes a hot, oversized partition that is slow to read and hard to balance. The fix is to bound the partition by time as well, for example by bucketing on channel plus a time window such as a range of days. Now each partition holds a manageable slice, active channels spread their history across many buckets, and no single partition grows forever. The trade-off is that reading a long scrollback may span more than one bucket, but that is a cheap, predictable cost compared to a single partition that never stops growing.
This kind of store buys throughput and smooth scaling by giving up things a relational database hands you for free. You largely give up rich cross row queries and joins, and you accept eventual consistency and the need to design around your access pattern up front. For chat, that is a good trade. You almost never need to join messages against something else in the hot path, and you always know you are reading by channel and time. When your queries are that predictable, modeling the storage tightly around them is a strength, not a limitation.
One more practical piece: a store like this handles deletes and updates through a mechanism that marks rows rather than removing them immediately, and those markers can pile up and slow reads if a workload does a lot of deletion. That is the sort of operational detail that does not show up in a whiteboard design but absolutely shows up in production, and being aware of it is part of choosing the tool honestly.
How does the real company do it? Discord moved messages off a general purpose relational database onto a wide column store, partitioning by channel and a time bucket and clustering by message id so recent history is a fast ordered read. They later migrated to a Cassandra compatible database for better performance and lower operational pain, but the data model stayed the same because the model was the real insight. In front of the store sits caching for hot channels so the most active conversations do not hammer the database on every read.
The lesson that generalizes is this: when data grows without bound and is always read the same way, pick a store that lets you model directly around that access pattern, and bound your partitions so nothing grows forever.
I wrote the full breakdown, with diagrams and the data model, here: https://www.systemdesign.academy/interview/design-discord
Top comments (0)