Guide

Chat history is written constantly, rarely changed, and read as "the latest N messages in channel X." That pattern suits a wide-column store such as Cassandra, ScyllaDB or Bigtable. These systems group rows into partitions and sort each partition by a clustering key. The design question is how to split those partitions so no single one grows too large.

Concept

A partition is the unit a wide-column store places on one node. A clustering key orders rows inside a partition. A time bucket is a fixed period, such as a week, used to split a partition over time.

The big idea

Think of one binder per channel per week. To read the latest messages, you open this week's binder. Older binders sit on the shelf, and you rarely open them. Old binders can be archived or deleted on a schedule.

Without buckets, a busy channel grows one partition forever. Reads and repairs slow down as it grows. Buckets cap the size of each partition.

The analogy breaks for hot channels. Even one week's binder can be overfilled by a very busy room. That case needs its own handling, which the large channels page covers.

Walking through it

The schema has a partition key and a clustering key.

Primary key:  (channel_id, time_bucket)   clustering: message_id / ts
Payload:      sender_id, body, headers, edit_tombstone, ...

Time buckets, often weekly, keep partitions manageable. They also let you expire or tier old buckets. Discord's engineering posts describe the hard parts at much larger scale, including hot channels and huge partitions. Treat bucketing and hot-channel splitting as deep-dive topics.

Attachments go to object storage. The message holds a reference, such as a key or a URL. Never put a multi-MB file in a message row.

Search is a separate index, such as Elasticsearch or OpenSearch, fed asynchronously from the write path. The send path must never wait on search. Slack-style products lean hard on search. Consumer messengers with end-to-end encryption often limit it, because the server can't read the bodies.

Deletes and redaction. Include deleted_at or a redacted flag in the schema from day one. Then clients can show "This message was deleted" without breaking the sequence. Deletions run as background jobs that mark rows in the store and the search index.

Common mistakes

  • One partition per channel, forever. Busy channels become enormous. Bucket by time.
  • Media in message rows. It bloats the store and slows every history read.
  • Indexing on the send path. A slow search cluster then slows every message.
  • No delete flag. A hard delete can break sequence gaps in client views.

Check yourself

  1. Why partition by channel and time bucket, not by channel alone? Channels grow without limit. Buckets cap each partition, and old buckets can be expired or tiered.
  2. Where should search indexing run? Asynchronously, off the send path, so sending never waits on search.

Key takeaways

  • Store chat history in a wide-column store, partitioned by channel and time bucket.
  • Keep attachments in object storage, with references in the message.
  • Index search asynchronously, away from the send path.
  • Add a deleted or redacted flag from the start.

Further learning

Discord's post on storing trillions of messages is the best public storage case study for chat.

How Discord Stores Trillions of MessagesEngineer Bo Ingram shares insight into how Discord shoulders its traffic and provides a platform for our users to communicate.discord.com

Next, read about large channels and receipts, which covers fan-out and read tracking at scale.