YeetCode
System Design · Advanced Designs

Design Discord: Real-Time Chat, Voice, and Presence at Scale

How to design Discord in a system design interview — the WebSocket gateway, Cassandra message store, WebRTC SFU voice, and presence fan-out at scale.

11 min readBy Bhavesh Singh
discordreal-time chatwebsocket gatewaywebrtc sfucassandrapresence

This article has an interactive companion

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode

Discord looks like a chat app, but the interesting engineering is everywhere the chat isn't. A single guild can hold a million members with a hundred thousand online at once, all watching the same channel list light up in real time. Voice channels carry live Opus audio between strangers on three continents. And the moment someone goes idle, a green dot has to turn yellow for exactly the people who care — and nobody else.

That combination — persistent connections, write-heavy message history, live media, and a presence system that quietly dwarfs everything else — is why "Design Discord" is a favorite Advanced-tier interview prompt. It forces you to reason about fan-out trees, wide-column storage, WebRTC topologies, and the difference between a problem that scales linearly and one that scales quadratically.

This guide walks the full interview rubric: requirements, capacity math with real numbers, the high-level architecture, the message data model, and the deep dives (presence, voice, permissions) where senior candidates separate themselves.

Functional and non-functional requirements

Scope the problem before drawing a single box. For Discord, the functional core is:

  • Guilds and channels — users join servers ("guilds"), each holding many text and voice channels.
  • Text messaging — send a message to a channel; everyone watching it sees it instantly and can scroll history.
  • Voice/video — join a voice channel and talk to everyone else in it with sub-200ms latency.
  • Presence — online / idle / offline / "playing X" status, visible to people who share a guild with you.
  • Permissions — roles and channel overrides decide who can read, send, or manage.

The non-functional requirements are where the design lives or dies:

RequirementTargetWhy it drives the design
Message latency< 100ms end-to-endPushes you to persistent WebSockets, not polling
Voice latency< 200ms mouth-to-earRules out server-side mixing; forces WebRTC + SFU
Availability99.99%Gateway and message store must tolerate node loss
DurabilityNo lost messagesReplicated, append-optimized store
Concurrency10M+ simultaneous connectionsSession sharding across thousands of gateway nodes

Read-vs-write shape matters too. Messages are append-heavy and read in reverse chronological order ("give me the last 50 in this channel"). Presence is update-heavy and read on demand. Those two access patterns want completely different storage, and conflating them is the classic junior mistake.

Capacity estimation

Put numbers on it early — they justify every later decision.

Assume 15M daily active users, 10M concurrent connections at peak, and an average user in 20 guilds. Messages: say 5 billion sent per day. That's about 58,000 messages/sec on average, with peaks 3–4x higher, so design for ~200K writes/sec.

Storage: at ~300 bytes per stored message (content, IDs, timestamps, metadata), 5B/day is ~1.5 TB/day, ~550 TB/year before replication. With a replication factor of 3 you cross a petabyte a year. Messages are effectively immutable and never deleted in bulk, so this only grows — the store has to be horizontally partitioned from day one.

Connections: 10M concurrent WebSockets can't live on one process. If each gateway node comfortably holds 30K sessions, you need ~330 gateway nodes just for connection termination at peak, before any headroom.

Now the fan-out. Take one busy guild: 1M members, 100K online, 500 text channels, peaking at 50 messages/sec across the whole guild. Naively pushing each message to all 100K online sessions is 50 msgs/sec × 300 bytes × 100K sessions ≈ 1.5 GB/s of egress for a single guild. That's large but tractable across the fleet. Presence is the real monster — more on that below.

High-level architecture

The center of gravity is the gateway: a fleet of servers each holding tens of thousands of persistent WebSocket connections. The gateway owns session lifecycle, authentication handshake, presence for its own connections, and — critically — it's the fan-out point that pushes events down to clients.

text
┌─────────────┐ clients ◄──────► │ Gateway │ (WebSocket, ~30K sessions/node, ~330 nodes) (WS) │ fleet │ └──────┬──────┘ │ subscribe / publish ┌──────┴──────┐ ┌─────┤ Event bus / ├─────┐ │ │ pub-sub │ │ ▼ └─────────────┘ ▼ ┌───────────────┐ ┌──────────────┐ │ Message svc │ │ Presence svc │ │ (Cassandra) │ │ (Redis view) │ └───────────────┘ └──────────────┘ Voice path (separate): client ◄─WebRTC/UDP─► Regional SFU media server Supporting: Auth · Attachments/CDN · Search (Kafka→Elasticsearch) · Push

The flow for a text message:

  1. Client sends the message over its WebSocket to a gateway node.
  2. Gateway calls the message service, which assigns a Snowflake ID and writes to Cassandra.
  3. The write is published to a guild event bus; gateway nodes that hold subscribers for that channel receive it.
  4. Each of those gateway nodes fans the event out to the individual client connections watching the channel.

Voice never touches this path. It runs over UDP against a regional SFU media server using WebRTC, which is a fundamentally different system co-located in the same regions.

The message data model

Messages are the highest-volume write and the most predictable read, so pick a store built for exactly that shape. A wide-column database — Cassandra or ScyllaDB — is the standard answer, and you should be able to defend both the choice and the schema.

The read pattern is always "the last N messages in a channel, newest first." So partition by channel, cluster by message ID descending:

sql
CREATE TABLE messages ( channel_id bigint, bucket int, -- time window, e.g. 10-day epoch message_id bigint, -- Snowflake: embeds a timestamp author_id bigint, content text, PRIMARY KEY ((channel_id, bucket), message_id) ) WITH CLUSTERING ORDER BY (message_id DESC);

Two schema decisions carry the design:

  • Bucketed partition key(channel_id, bucket) instead of raw channel_id. A channel that has run for years would otherwise grow one unbounded partition, which wide-column stores handle badly. Bucketing by a time window (Discord famously reworked this after the "10-day bucket" era) caps partition size and keeps compactions healthy.
  • Snowflake message IDs — a Snowflake ID embeds its creation timestamp in the high bits, so clustering by message_id DESC gives you time-ordered reads for free, no separate timestamp column needed for ordering. It also gives globally unique, roughly-sortable IDs without a coordination bottleneck.

Because rows are append-only and immutable, there are almost no updates and no in-place deletes on the hot path — exactly the workload Cassandra's LSM-tree storage is optimized for. Ephemeral data (typing indicators, short-lived events) gets a TTL so tombstones don't pile up.

Deep dive: presence without melting the cluster

Presence is deceptively expensive because it's quadratic if you do it naively. If every login broadcast "I'm online" to everyone who shares a guild, a user in 20 large guilds triggers hundreds of thousands of notifications — multiplied by millions of logins a minute.

The fixes, in order of importance:

  • Lazy guilds. A client only subscribes to what it's currently looking at. Open a guild with 500 channels and you subscribe to the handful visible, not all 500. Member lists load on demand when you open the sidebar, not on connect.
  • Authoritative-per-node model. Each gateway node owns the presence truth for its connected sessions and publishes only deltas to a presence service backed by an in-memory view (Redis or a custom store), keyed by userId → {state, guilds}.
  • Batching and coalescing. Updates flush every few hundred milliseconds, and rapid flaps (online→idle→online) are collapsed so a flaky connection can't spam the fleet.
  • On-demand fan-out. A state change only reaches people currently watching a member list that includes you — not every mutual-guild member.

The mental model to state in an interview: messages are the obvious hot path, but presence is the actual one. Getting that ordering right is a senior signal.

Deep dive: how voice works, and why an SFU

Voice runs over WebRTC, but the topology choice is the whole question. Three options:

TopologyHow it worksWhy it fails / wins
Mesh (P2P)Every client sends its stream to every other clientEach client uploads N−1 streams; dies past ~4 people
MCUServer mixes all streams into one, sends it backServer-side decode+mix+encode: CPU-heavy, adds latency
SFUServer forwards each stream selectively, no mixingLow latency, per-stream control, scales horizontally

Discord uses an SFU (Selective Forwarding Unit). Each client encodes its microphone once with Opus and uploads a single stream to a regional media server. The SFU forwards that stream to the other subscribers in the channel, handling jitter buffers, packet-loss concealment, and simulcast for video (sending different quality layers so a phone and a desktop each get an appropriate one).

The SFU never decodes and re-mixes audio, so it stays cheap per stream and adds minimal latency — the reason a mesh (unscalable uploads) and an MCU (expensive mixing) both lose. It also enables per-stream features: server-side mute, priority speaker, and dropping a stream when a user isn't the active speaker.

Permissions are a bitmask, resolved through a stack: @everyone role → guild roles → channel overwrites → member overwrites. Walking that stack on every message write would burn CPU on hot channels, so you compute the effective permissions once per (guild, user) and cache the resulting bitmask. A message write then costs a single O(1) bitwise AND against SEND_MESSAGES. Cache invalidation rides a pub/sub tag fired on any role or overwrite change. Threads inherit their parent channel's permissions with a small override set, so the same cache generalizes.

Search can't run against Cassandra — it's not built for full-text queries. Instead, stream messages off the write path through Kafka into an indexer that writes an inverted index (Elasticsearch or Manticore), partitioned by guild_id with time-based indices so old data rolls off to cheaper storage. Queries are scoped by guild and channel and filtered by the same cached effective-permissions set, so a user never sees results from a channel they can't read. Indexing is async with a refresh interval of a few seconds — trading a little freshness for a lot of throughput.

How this shows up in interviews

The prompt is usually open-ended: "Design Discord" or "design a real-time chat with voice." Interviewers are probing for a few specific instincts.

First, do you reach for persistent connections immediately? Polling for a sub-100ms chat is an instant red flag. Second, do you pick the right store for the message shape and defend the partition key? "Cassandra" alone is table stakes; explaining bucketed partitions and Snowflake clustering is the differentiator. Third — the trap — do you realize presence, not messaging, is the scaling bottleneck, and can you describe lazy guilds and batched deltas? Fourth, on voice, can you name mesh/MCU/SFU and justify the SFU on latency and CPU grounds?

Strong candidates also volunteer what they'd cut: end-to-end encryption, for instance, breaks server-side search, moderation, and rich push notifications, which is why large chat products generally skip it. Naming the tradeoff you chose not to make is often worth more than another feature.

Further learning

Related system designs that reuse these building blocks:

  • Design Slack — the same real-time messaging core, tuned for team channels and heavy search rather than voice and massive public guilds.
  • Design Google Drive — attachments and sync at scale, the storage side Discord leans on for file uploads.
  • Design Ticketmaster — another fan-out-under-contention problem, where the bottleneck is concurrency rather than presence.
  • Design a Payment System — where consistency and durability guarantees, glossed over in chat, become the entire problem.

FAQ

Why does Discord store messages in Cassandra instead of a relational database?

Messages are append-heavy, immutable, and almost always read as "the last N in this channel, newest first" — a pattern that scales to trillions of rows. A wide-column store like Cassandra or ScyllaDB partitions naturally by channel and is built on an LSM-tree that's optimized for exactly this write-once, range-read-by-key workload. A relational database would struggle with the write volume and the petabyte-per-year growth, and it offers nothing you need for a schema this simple. The catch is partition design: you bucket the partition key by time window so a long-lived channel doesn't grow one unbounded partition.

Why is presence harder to scale than messaging in a chat system?

Because presence is quadratic if implemented naively. A message goes to the people watching one channel, but a status change potentially concerns everyone who shares any guild with you — and a heavy user is in dozens of guilds with tens of thousands of members each. Broadcasting every login and idle-flip to all of them would generate orders of magnitude more traffic than actual messages. The solution is to make it lazy and on-demand: clients only subscribe to member lists they're actively viewing, each gateway node owns presence for its own sessions, and deltas are batched and coalesced every few hundred milliseconds so connection flaps don't storm the cluster.

Why does Discord use an SFU for voice instead of peer-to-peer or a mixing server?

A peer-to-peer mesh requires each client to upload its stream to every other participant, so upload bandwidth grows with the number of people and collapses past roughly four speakers. A mixing server (MCU) decodes every stream, mixes them, and re-encodes — which is CPU-expensive and adds latency from the extra processing. An SFU (Selective Forwarding Unit) splits the difference: each client uploads exactly one Opus stream, and the server just forwards streams to subscribers without decoding or mixing. That keeps latency low and per-stream cost cheap, scales horizontally across regional media servers, and still allows per-stream control like server-side mute and priority speaker.

How does a single message reach 100,000 online users in one guild?

Not from one process — the fan-out is a tree. The message is written once, then published to a guild event bus. Gateway nodes that hold subscribers for that channel pick up the event and each fans it out to its own local connections, so the work is spread across the whole gateway fleet (hundreds of nodes, tens of thousands of sessions each) rather than bottlenecked in one place. Combined with lazy guilds — clients only subscribe to channels they're actively viewing — the effective fan-out per message is far smaller than the raw online count, which keeps egress manageable even for million-member guilds.

Why don't large chat platforms like Discord ship end-to-end encryption?

End-to-end encryption would mean adopting a group protocol like MLS (Messaging Layer Security), where each channel is a cryptographic group with forward-secret epoch keys and the server only ever sees ciphertext. The problem is what breaks: server-side search stops working because the index can't read message content, automated moderation and spam detection lose all visibility, and push notifications can carry only encrypted metadata instead of a message preview. Rebuilding search as client-side or encrypted-search schemes is prohibitively slow at Discord's scale. For a product whose value depends on search, moderation, and rich notifications, the tradeoff usually isn't worth it — which is why Slack and Discord generally choose not to ship E2E for regular channels.

Make it stick: run this one yourself

Don't just read it — step through it interactively, one state change at a time.

Practice this topic on YeetCode