SysDesignPrep.com
System design interview question

Design Discord

Community chat and voice: servers with a million members, years of history, roles and presence, for 20 M concurrent users.

Last updated 2026-10-27. Difficulty: hard. Patterns: real-time, fan-out, actors, presence, wide-column. Reported at Discord, Meta, Microsoft, Slack, Roblox, Riot Games.

Walk through a strong candidate's answer, turn by turn.

The interviewer asks, the candidate answers and draws, and you press Next. Skip to any stage or key moment, and ask the AI Mentor anything along the way.

Or sit it in the style of Discord · Meta · Microsoft · Slack, run as that company’s round is reported to go.

The short answer

How to design Discord in a system design interview, in five steps:

  1. Scope it. Servers (guilds) with text channels, send and receive messages in real time, history that scrolls back years, roles and permissions, presence and typing, member list, unread indicators and mention counts, voice channels, bots.
  2. Set the targets. Scale: 150 M MAU · 20 M concurrent; Largest guild: 1 M members · 100 k online; Delivery latency: p99 < 500 ms; Ordering: total order per channel.
  3. Do the numbers. Messages per second: ~46 k avg · ~115 k peak; Message deliveries per second: ~4.6 M avg · ~12 M peak; Gateway nodes: ~400.
  4. Draw the design. API edge, API service, Data services, Messages store, Sessions & presence, Event log, Read states, Gateway, Guild actors.
  5. Go deep where it is hard.
    • One process per guild, and the guilds that break it → Actor owns state; relays own slices of sessions; active, passive and lazy delivery.
    • Gateway sessions: sequence numbers, resume, shards → Sessions with per-session sequence numbers, a bounded replay buffer and RESUME.
    • Messages: buckets, hot channels and coalescing → Wide-column (channel_id, bucket) with per-channel bucket width, behind data services that route by channel and coalesce.
    • Permissions: role bitfields and channel overwrites → Compute from bitfields on demand, cached per role set and channel, invalidated on change.
    • Presence: the biggest stream, mostly dropped → Coalesce per user, deliver only to sessions displaying the member, drop the rest.
    • Voice: regional SFUs beside the chat path → Regional SFUs allocated by the guild per voice channel, signalling via the gateway, media over UDP.

Functional requirements

  • Servers (guilds) with text channels. A guild has channels grouped in categories and can have 1 M+ members. DMs and group DMs are private channels outside any guild and work as in Slack.
  • Send and receive messages in real time. Text, mentions, replies, edits and deletes. Sent over REST, received over one gateway WebSocket per client.
  • History that scrolls back years. Newest page on open, then pages before a message id; jump to a message link.
  • Roles and permissions. Roles carry permission bitfields; channels override them per role or per member. Decides who sees a channel, who can send, who can ping @everyone. Slowmode per channel.
  • Presence and typing. Online, idle, do not disturb, offline, plus activities such as "Playing X". Typing shows in the channel you have open.
  • Member list. The sidebar of who is online, grouped by role. Must work in a guild with 100 k people online.
  • Unread indicators and mention counts. Per user per channel, the same on every device. @everyone and role pings count too.
  • Voice channels. Join a channel and talk with everyone in it. Video and screen share ride the same servers and are not designed separately here.
  • Bots. Bots connect to the gateway like users. A bot in a million guilds must split its connection into shards.
  • Out of scope. Search, push notifications, the file upload pipeline, AutoMod and moderation (see Content Moderation), Nitro and payments, the SFU internals (see Zoom).

Non-functional requirements

  • Scale (150 M MAU · 20 M concurrent). Concurrency sizes the gateway fleet; 4 B messages a day size storage. Neither is the hardest number in the design.
  • Largest guild (1 M members · 100 k online). The requirement that drives the hardest tradeoff. One process orders a guild’s events, so everything that scales with online sessions has to be moved out of it or cut down to what each client displays.
  • Delivery latency (p99 < 500 ms). From send to every session viewing the channel in the region, in a 100 k-online guild too. Sessions not viewing it get badges within seconds rather than every message.
  • Ordering (total order per channel). Snowflake ids sort by time; each guild applies its events in one order, so a role change and a message never race.
  • Durability (no loss after 200 OK). A message is written at quorum before it is acknowledged or delivered. The live stream may drop an event, because history is the source of truth.
  • Availability (99.99 % messaging). Presence, typing and member lists degrade first, on purpose. Voice runs on its own fleet, so a chat incident does not cut calls.
  • Voice latency (< 200 ms mouth to ear). Within a region. Drives regional voice servers and media over UDP.
  • Permissions (checked at send, read and fan-out). A member who loses a role stops receiving that channel from the next event on. A hidden channel never leaks through history, mentions or the member list.

Back-of-envelope estimates

  • Messages per second: ~46 k avg · ~115 k peak. 4 B messages/day ÷ 86 400 s ≈ 46 k/s. Evenings run about 2.5× the average, so ~115 k/s at peak. One partition takes thousands of writes a second, so writes are not the hard part.
  • Message deliveries per second: ~4.6 M avg · ~12 M peak. DMs reach one or two sessions and big guilds thousands, but delivering only to sessions that have the guild or the channel open keeps the average near 100 sessions per message: 46 k × 100 = 4.6 M deliveries/s, ~12 M at peak. Each one is filtered by permission first.
  • Gateway nodes: ~400. 20 M concurrent sessions at peak. A session holds its guild subscriptions and a replay buffer budgeted at ~100 KB, so 50 k sessions is ~5 GB per node with CPU left for compression and delivery: 20 M ÷ 50 k = 400 nodes.
  • Presence deliveries, naive: ~170 M/s. 20 M users each changing status or activity about every 10 minutes is 20 M ÷ 600 = 33 k changes/s. Sending each change to the ~5,000 online members who share a guild with that user: 33 k × 5,000 ≈ 170 M/s, about 35× the message fan-out. Sending only to the ~50 sessions displaying the member brings it to ~1.7 M/s. Presence, not chat, is the biggest stream.
  • One event to the biggest guild: ~200 ms of one core. 100 k online sessions × ~2 µs per send = 200 ms for a single event from one process. A big guild produces dozens of events a second (messages, joins, role and voice changes), so one process cannot fan out alone. Relays of up to 15 k sessions turn it into ~7 sends from the guild.
  • Message storage per year: ~440 TB. ~300 B per message with ids, author, flags and attachment references (files live in object storage). 4 B × 300 B = 1.2 TB/day ≈ 440 TB/yr before replication, ~1.3 PB with three replicas, about 1.5 trillion messages a year.
  • Busiest channel per day: ~520 MB. A channel sustaining 20 messages/s stores 20 × 86 400 × 300 B ≈ 520 MB/day, so a 10-day bucket would be a 5 GB partition. Keeping partitions under ~100 MB means a 1-hour bucket for that channel (~22 MB), while a typical channel fills a 10-day bucket with a few megabytes.
  • Voice egress: ~360 Gbps. Assume 3 M people in voice. Opus at ~64 kbps plus packet overhead is ~80 kbps per stream, and a listener receives about 1.5 streams because clients send only while speaking: ~120 kbps and ~75 packets a second each. 3 M × 120 kbps = 360 Gbps, more bandwidth than all of chat.

Components

  • Discord client (desktop · web · mobile · bots): Holds one gateway WebSocket for everything pushed to it (messages, presence, member-list ranges, voice state) and uses REST for actions such as send, edit and history. Remembers the last sequence number it saw, so a dropped socket resumes instead of reloading. Sends a nonce with each message so a retried POST cannot post twice. A bot is the same client without a UI, and a big bot opens one connection per shard.
  • API edge (REST · auth · rate limits): Terminates TLS, validates the token and applies rate limits before anything else runs: per-route buckets keyed by the route and its major id (channel, guild, webhook), a global limit per user or bot (Discord documents 50 requests a second for bots), and a ban for clients that keep sending invalid requests. Returns 429 with retry_after and bucket headers so good clients pace themselves. Stateless; counters live in a sharded in-memory store.
  • API service (send · history · Snowflake ids): The write path for messages and the front door for history. On a send it asks the guild for the member’s permissions and slowmode, assigns a Snowflake id (42 bits of milliseconds, then worker, process and sequence), writes through the data service, returns 200, then hands the event to the guild for live fan-out and to the event log. It never fans out itself, so a message costs it the same in a 10-member guild and a 1 M-member one.
  • Data services (Rust · routed by channel): A thin layer between the API and the messages store, one endpoint per query. Requests are routed by consistent hashing on channel_id, so every read of a channel lands on the same instance, and identical reads in flight at the same time are coalesced into one database query whose result goes to every waiter. Discord has written about building exactly this in Rust in front of ScyllaDB to tame hot partitions.
  • Messages store (ScyllaDB · (channel, bucket)): Wide-column store holding every message: partition (channel_id, bucket), clustering message_id descending, with the bucket computed from the Snowflake’s timestamp. Buckets default to 10 days, the width Discord described in 2017, and the busiest channels get narrower ones so no partition passes ~100 MB. LOCAL_QUORUM writes; about 440 TB a year before replication.
  • Sessions & presence (user → sessions · status): Knows every live session of every user and folds them into one status (online beats idle) plus activities like "Playing X". Coalesces changes so a user broadcasts at most one update every few seconds, sends it to friends’ sessions, and hands it to the user’s guilds, which decide who actually sees it. Also the directory for events aimed at one user, such as a read ack to their other devices.
  • Event log (Kafka · key = channel_id): Durable stream of message events for consumers that must not miss one: mention counting in read states, plus search, AutoMod and push, which are out of scope here. Keyed by channel_id so a channel’s events stay in order. Live delivery deliberately does not go through it: a consumer hop adds latency, and a missed live event is recovered from history anyway.
  • Read states (user × channel · LRU): One record per user per channel: the last message id read, a mention count and a few flags. Held in memory in an LRU sharded by user_id, with each change committed to a wide-column table about 30 seconds later or on eviction, which is how Discord has described its own read states service (rewritten from Go to Rust to remove GC pauses). Kept apart from messages because it is per user and changes on every read, while a message is written once.
  • Gateway (WebSocket · ~50 k sessions/node): Holds the persistent WebSockets. Each session has an id, a sequence number stamped on every event it is sent, and a bounded replay buffer, so a client that drops can RESUME with its last sequence number and get only what it missed. On identify it subscribes the session to each of its guilds using a local copy of the guild ring, tracks which guild and channel the client is looking at, and compresses the stream. Bots connect here too, one session per shard.
  • Guild actors (one process per guild): Each guild is one in-memory process that owns its state (channels, roles, overwrites, members, voice states) and its connected sessions, so every event in the guild is applied in one order. It caches each role set’s computed permissions per channel, filters every event by VIEW_CHANNEL, and sends one batch per gateway node rather than one message per session. Placed on guild nodes by a hash ring on guild_id; a dead node’s guilds restart elsewhere from the guilds DB.
  • Guilds DB (keyed by guild_id): Durable home of guild state: guilds, channels, roles with their permission bitfields, channel overwrites, members and their role ids. Guild actors load from it on start and write through on every change, so a restarted actor rebuilds exactly. A 1 M-member guild is about a million member rows of ~100 bytes, ~100 MB, which loads in seconds.
  • Voice servers (regional SFUs · UDP): Selective forwarding units in many regions: each speaker sends Opus packets once and the server forwards them to everyone else in the voice channel without mixing. One server hosts a channel’s call, chosen by the guild from the least-loaded healthy servers in the region nearest the participants. Signalling is a WebSocket to the server, opened with a token the guild hands out over the gateway; media is UDP and never peer to peer, which keeps users’ IP addresses private.
  • Fan-out relays (≤ 15 k sessions each): For guilds too big for one process to fan out, the guild hands its session list to relays, each owning a slice of up to about 15,000 sessions, the figure Discord has published. The guild sends an event once per relay; each relay filters its slice (who is viewing the channel, who may see it, who is passive) and batches by gateway node. Relays also take session joins and leaves, so the guild process stays free for state changes.
  • Service discovery (etcd · guild ring · voice load): Small, strongly consistent registry of which nodes are alive. It holds the hash ring that places guild processes on guild nodes, which every gateway and API node keeps a local copy of and watches, and the health and load of every voice server, which the guild reads when it allocates a call. Read from local caches, so it is never on the per-message path.

User flows

  1. Send a message in a channel. The core path. The message is durable before the sender sees it posted, and every session that should see it gets it within half a second, with permissions checked twice: once to send, once per recipient.
    1. Client POSTs the message with a nonce; the edge authenticates it and applies rate limits. Limits apply per route and major id (this channel) and per user, before any work is done; slowmode is checked later because it needs guild state. The nonce lets a retry after a timeout return the original message instead of posting twice, and lets the client match the server’s copy to the one it drew as pending.
    2. API service asks the guild actor whether this member may send here and whether slowmode allows it. The guild holds computed permissions in memory, so the check is a lookup: VIEW_CHANNEL and SEND_MESSAGES, MENTION_EVERYONE if the text pings @everyone, and the timeout flag. Slowmode is a last-sent time per (channel, member) in the same process; members with MANAGE_MESSAGES or MANAGE_CHANNELS skip it.
    3. API assigns a Snowflake id and writes the message through the data service. The id’s top 42 bits are milliseconds since Discord’s 2015 epoch, so the bucket comes from the id with no lookup: bucket = timestamp ÷ the channel’s bucket width. The LOCAL_QUORUM write is the durability point; nobody else is shown the message before it. About 300 bytes per message with metadata.
    4. API returns the stored message to the sender. The client swaps its pending copy for the real one, matched by nonce. The sender’s own sessions also receive the MESSAGE_CREATE a moment later and drop it as a duplicate by id.
    5. API dispatches MESSAGE_CREATE to the guild actor, which picks the recipient sessions and sends one batch per gateway node. Recipients are sessions whose member has VIEW_CHANNEL here, read from the permission cache: a second check, because a role change can land between the send and the fan-out. In a small guild every online member’s session gets it; in a large one only sessions viewing the channel do. Batching by node means a guild with 2,000 online spread over 300 nodes costs 300 sends, not 2,000.
    6. Each gateway node stamps the next sequence number for each session, buffers the event and writes it to the socket. Sequence numbers are per session, not per channel: they order everything a session receives (messages, presence, member updates, voice states) and are what RESUME uses. Clients still sort a channel’s messages by id, which fixes the rare pair dispatched out of id order by two API nodes.
    7. API publishes the event to the log; the read-state service raises mention counts for the users it pings. Mention counts must survive a guild actor or a gateway dying mid-fan-out, so they come from the durable log, not the live path. The consumer is idempotent on (user, channel, message id), so a redelivered event cannot count twice. Plain unread needs no write at all: a channel is unread when its last message id is above the user’s last read id.
  2. Open a busy channel and scroll back. The main read path. Opening a channel is a REST read of the newest page; the hard case is thousands of people opening the same channel within seconds, which would aim a storm of identical reads at one partition.
    1. Client tells its gateway session which guild and channel it is now looking at. This makes the session active for that channel: in large guilds only sessions viewing a channel get its messages and typing. The gateway forwards it to the guild, or to the relay that owns the session.
    2. Client asks for the newest 50 messages; the API checks VIEW_CHANNEL and READ_MESSAGE_HISTORY with the guild. Reading history is a separate permission from viewing: a channel can show new messages to a member without exposing what was said before. The check is the same cached lookup as on send.
    3. API calls the data service instance that owns this channel on the hash ring. Routing by channel_id is what makes coalescing work: every reader of a channel, from every API node, arrives at the same instance. If an instance dies its channels move to the next one on the ring, losing nothing but in-flight coalescing.
    4. If an identical query is already in flight, the request joins it; otherwise the data service reads the newest bucket. With 30,000 people opening an announcement in a minute, reads arrive at up to ~3,000 a second, but the store sees about one query per in-flight window: at ~5 ms per read, at most ~200 a second however many clients ask. If the newest bucket holds fewer than 50 messages, the service also reads the previous non-empty bucket and merges.
    5. Scrolling back asks for messages before the oldest id on screen; the service walks buckets backwards, skipping empty ones. Any id’s bucket can be computed from the id, so "before X" starts in X’s bucket. A quiet channel can have a hundred empty 10-day buckets in five years; a small per-channel list of non-empty buckets lets the walk jump to the next one with data, which is how Discord described fixing the same problem.
    6. Client acks the newest message it has read; the read-state service moves the marker and clears the mention count. Acks are debounced on the client (on channel switch or after a few idle seconds), and the service updates memory at once and commits to its table 30 seconds later, so a burst of acks becomes one write. Losing a few seconds of acks in a crash only brings back an unread dot.
    7. The ack reaches the user’s other devices so their badges clear too. The sessions directory knows the user’s other live sessions and the gateway nodes they sit on. Read on the laptop, cleared on the phone within a second.
  3. An @everyone in a 1 M-member guild. The scale-breaking case: 100,000 members online, one message that pings them all, and 30,000 of them opening the channel within a minute. Every per-session cost is multiplied by 100,000, so the design decides who gets what.
    1. A moderator posts an announcement with @everyone; the API checks MENTION_EVERYONE and stores it as usual. Without MENTION_EVERYONE the message still posts but the ping is ignored, so the permission gates the expensive part, not the message. Storing it costs the same as in any other guild.
    2. The guild actor, which hands fan-out to relays above a size threshold, sends the event once to each of its seven relays. 100,000 sessions at about 2 µs per send would be 200 ms of one core for one event, and a guild this size produces dozens of events a second. Seven relays of up to 15,000 sessions turn that into seven sends, so the one process that orders the guild’s state never does per-session work.
    3. Each relay filters its slice: the full message for sessions viewing the channel, a mention badge for everyone else who can see it. About 90 % of sessions in a big guild are passive, Discord’s own figure: the guild is not open on that device. Passive sessions get a 100-byte mention hint, not the message, and sessions without VIEW_CHANNEL get nothing. Each relay groups by gateway node, so its 15,000 sessions cost at most a few hundred batches.
    4. Mention counts are not written per member: the read-state service records one @everyone mention for the channel. Writing a counter for each of 1 M members would be a million writes for one message. Instead each channel keeps a short list of broad mentions (@everyone, @here, role pings), and a user’s count is their direct mentions plus the broad ones after their read marker that apply to them. MENTION_EVERYONE is rare and rate limited, so the list stays tiny.
    5. 30,000 members open the channel in the next minute; their history reads coalesce at the data service. They all ask for the same newest page, so nearly every request joins a query already in flight. The partition holding today’s bucket sees about a hundred queries a second instead of thousands, and its three replicas are never the bottleneck.
    6. Opening the guild, each client subscribes only to the member-list ranges it is showing. The sidebar shows about 40 rows of a sorted list of 100,000 online members. The client asks for rows 0 to 99 and gets that slice, then insert, update and delete operations on it as members come and go. Offline members are not listed at all in big guilds.
    7. A member’s presence changes; the guild forwards it only to sessions whose subscribed ranges show that member. Of 100,000 online sessions, the ones displaying this member are a few hundred at most; everyone else never hears about it. Above a size threshold the guild sends no presence outside list ranges at all, and the online count in the header is approximate.
  4. A dropped socket, then a dead gateway node. Phones lose signal constantly, so reconnecting must be cheap: a resume replays only the missed events. When a whole gateway node dies its sessions are gone, and 50,000 clients must identify again without stampeding the guild tier.
    1. A phone switches networks and its socket closes; the gateway keeps the session and keeps buffering its events. The session stays alive, detached, for a grace period of about two minutes while its replay buffer fills. Presence does not flip to offline during the grace, so friends never see a flap.
    2. The client reconnects to its resume URL and sends RESUME with its session id and last sequence number. A resume costs a few kilobytes and touches no guild: the gateway replays every event after 4182 from its own buffer, in order. A full identify for someone in 100 guilds means 100 guild subscriptions and a READY of hundreds of kilobytes.
    3. If the gap is older than the buffer holds, the gateway refuses the resume and the client identifies from scratch. The buffer is bounded per session (about 2 minutes or 1 MB), so a laptop closed for an hour cannot resume. INVALID_SESSION tells it to identify; history is then read from the store, which is the source of truth, so nothing is lost, only slower.
    4. Later the gateway node itself crashes; guilds and relays see its sessions vanish and drop them. The node’s 50,000 sessions and their buffers lived in its memory and are gone. Guild actors and relays monitor the gateway nodes they deliver to and remove a dead node’s sessions, so fan-out stops sending into it within seconds.
    5. Clients’ resumes fail, so they back off with jitter and identify on other nodes through an admission queue. 50,000 identifies in one second would be millions of guild subscriptions in a burst. Jittered backoff spreads them over tens of seconds, and each node admits identifies at a fixed rate, the same idea as the identify concurrency limits Discord documents for bots.
    6. The new node subscribes the session to each guild via its cached guild ring; a guild with nobody online is loaded from the guilds DB first. Each guild replies with what this session needs: channels, roles and the member’s own data, plus the online member list only for small guilds. Big guilds send no member list until the client subscribes to ranges, which keeps a heavy user’s identify from pulling megabytes. Actors for guilds idle for a while are unloaded and come back from the DB in milliseconds.
    7. The gateway loads the user’s read states and sends READY, then a GUILD_CREATE as each guild answers. READY goes out as soon as the session exists, with guilds marked unavailable, and each guild arrives as its own event, so one slow guild does not hold up the rest. Read states come from the in-memory LRU; a user online minutes ago is almost always still cached.
    8. Presence hears the user is online again but broadcasts nothing, because the status never changed. The presence service kept the user online through the reconnect window, so friends and guilds see no offline and online flap. This is why presence is debounced rather than mirrored from socket events.
  5. Join a voice channel and talk. Voice runs on its own fleet: the gateway carries only signalling, and media goes over UDP to a voice server near the participants. Chat and voice fail independently.
    1. Client sends a voice state update over the gateway: join voice channel V. Voice state belongs to the guild like everything else, so joining, muting and moving channels are ordered with the guild’s other events. CONNECT and the channel’s user limit are checked from the permission cache.
    2. If V has no voice server yet, the guild picks the least-loaded healthy one in the nearest region. Voice servers report health and load to discovery; the guild reads the candidates for the region nearest the first participant, or a region pinned on the channel. Everyone in V uses that one server, so servers never forward to each other for an ordinary call. Discord’s 2018 post describes the same allocation through etcd, then per guild rather than per channel.
    3. The guild records the voice state, tells the guild who joined, and sends this session the server’s address and a token. VOICE_STATE_UPDATE goes to every session that can see V, which is what draws you in the channel’s participant list. The token is signed, short-lived and names the user, channel and server, so the voice server can admit the client without calling the guild.
    4. Client opens a signalling WebSocket to the voice server, then starts sending UDP media. Signalling agrees the encryption mode and keys, the client’s SSRC and the UDP address; then audio flows as Opus, ~64 kbps plus headers, about 80 kbps per speaker in 20 ms frames. Clients send only while speaking, so a 10-person channel usually carries one or two streams.
    5. The voice server forwards each speaker’s packets to everyone else in the channel without decoding them. An SFU copies packets and mixes nothing, so each listener controls per-user volume and the server’s cost is packets, not CPU. A listener with two people talking receives about 160 kbps. All media goes through the server rather than peer to peer, which keeps users’ IP addresses private.
    6. The voice server dies; clients notice, the guild allocates a new one and the call carries on. The dead server drops out of discovery when it misses health checks, and clients see their voice socket close and report it over the gateway. The guild picks a new server and sends every participant a fresh VOICE_SERVER_UPDATE, and audio is back in a second or two. Text chat never noticed, because it never touched the voice fleet.

Deep dives

One process per guild, and the guilds that break it

Why route every event in a guild through one process, and what happens when that guild has 100,000 people online?

A guild’s state changes all the time: messages, edits, members joining, role changes, voice states, presence. If several servers applied those changes concurrently, a role removal and a message could race and a member could receive a message from a channel they just lost. One owner per guild gives one order for all of it, and keeps the state in memory where a permission check is a lookup. The cost is that the owner is one thread, and fan-out work grows with the square of guild size: Discord put it as 1,000 online users each saying one message being a million notifications, and 100,000 being ten billion. At about 2 µs per send, one event to 100,000 sessions is 200 ms of that thread.

Two observations bound it. Most per-session work is filtering and copying, which needs a read-only view of guild state rather than the owner itself, so it can be split off. And most sessions in a big guild are not looking at it: Discord has reported about 90 % of user-guild connections in large servers as passive. So the owner keeps the state and the order, and everything that scales with sessions moves elsewhere and is cut down to what each session actually displays.

  • Guild actor fans out directly, batched per gateway node situational: guilds below about 15,000 online sessions, which is almost all of them
  • Actor owns state; relays own slices of sessions; active, passive and lazy delivery chosen
  • Per-channel pub/sub topics that gateways subscribe to rejected
  • Shard one guild across several actors by channel rejected

The answer: One actor per guild, placed on guild nodes by a hash ring on guild_id, owns the state and decides the order of every event. Small guilds fan out directly, grouped by gateway node the way Discord’s Manifold library groups processes by node, so a send costs one message per node. Above about 15,000 online sessions the actor hands its session list to relays of up to 15,000 sessions each (Discord’s published figure) and sends each event once per relay, with state changes on the same ordered stream so a relay never filters a message with stale permissions. Delivery is tiered: sessions viewing a channel get its messages and typing; passive sessions get mention badges and a coalesced unread hint; the member list is sent only as the ranges each client shows. For a guild with 100,000 online that is about seven sends per event from the owner and roughly 10,000 full deliveries instead of 100,000. Discord has described this path in its write-up on Midjourney’s server: one Elixir process per guild, passive sessions, relays, and member data in ETS so other processes can read it.

The node running a big guild’s actor crashes. What happens?

Sessions see the guild go unavailable, and the ring assigns it to the next node, which loads it from the guilds DB: a 1 M-member guild is about 100 MB of member rows and loads in seconds. Gateways resubscribe their sessions and clients get a fresh GUILD_CREATE. No message is lost, because every message was written to the store before dispatch; what is lost is a few seconds of live pushes, which clients refill by reading the newest page when the guild returns. Slowmode timers and typing are in memory and simply reset.

How do relays see guild state without asking the owner about every event?

The owner sends state changes down the same ordered stream as events, so each relay keeps a replica that is always at least as new as the event it is filtering. On the owner’s own machine that replica can be a shared read-only table, which is what Discord does with member data in ETS; on other machines it is a copy fed by the stream. Every event carries the state version it was produced at, so a relay can assert it never filters with older permissions.

Why not make every session active and buy more hardware?

Because the cost is multiplicative, not additive. A guild with 100,000 online producing 50 events a second is 5 M deliveries a second if everyone is active, the same as all message fan-out in the system on average. With 90 % passive and messages scoped to viewers of the channel, it is a few hundred thousand. It is also what users expect: a guild that is not on screen needs a badge, not every message.

Can two relays deliver the guild’s events in different orders?

Each relay receives events from the owner in the owner’s order on one stream and delivers to its sessions in that order, so every session sees the guild’s events in the same order. Two sessions on different relays may see an event a few milliseconds apart, which nobody can observe. Within a channel clients also sort by message id.

How do you know a guild process is keeping up?

Measure event age: the time from an event entering the owner’s queue to its dispatch, per guild, with an alert on the p99 of the largest guilds, plus queue length. When it grows, the owner sheds the least important work first: presence outside list ranges, then typing, then member-list updates, and messages last. The biggest guilds also get dedicated nodes, so a noisy neighbour cannot slow them.

Gateway sessions: sequence numbers, resume, shards

A phone loses signal for 20 seconds. How does it catch up without reloading everything, and how does a bot that is in a million guilds connect at all?

Connecting is expensive. A user in 100 guilds needs 100 guild subscriptions and a READY of hundreds of kilobytes, and one gateway node’s 50,000 users reconnecting together is millions of subscriptions. Reconnects are constant: mobile networks drop every few minutes, laptops sleep, deploys restart nodes. If every reconnect were a full identify, the guild tier would spend much of its time rebuilding sessions.

Unlike chat history, the event stream has no single cursor to sync from. It mixes messages from many channels with presence, member updates, role changes and voice states, most of which are not stored anywhere a client could query by position. What a session does have is its own sequence: every event sent to it is numbered. If the server keeps the last couple of minutes of that sequence, a reconnect can be a replay of the gap.

  • Re-identify on every reconnect rejected
  • Sessions with per-session sequence numbers, a bounded replay buffer and RESUME chosen
  • A durable event log per user for unlimited resume rejected
  • Per-channel cursors and REST sync, as in Slack situational: the fallback after INVALID_SESSION: the client reloads the newest page of the channels it shows

The answer: Every session has an id and a sequence number that the gateway stamps on each dispatch; the client sends its latest sequence in each heartbeat (about every 45 s, the interval Discord’s docs use as an example). The gateway keeps a per-session replay buffer bounded at about 2 minutes or 1 MB and keeps a disconnected session alive for that long. RESUME with (session_id, seq) replays the gap and ends with RESUMED; anything older gets INVALID_SESSION and a fresh identify, after which the client reloads the channels on screen from the store. Planned restarts drain a node by handing its sessions and buffers to another node and telling clients to reconnect there, so a deploy costs resumes, not identifies. Identifies are admitted at a fixed rate per node behind jittered client backoff, so a crashed node’s 50,000 sessions return over tens of seconds. Bots shard: a bot in 1 M guilds needs at least 400 connections at the documented limit of 2,500 guilds per shard, and each guild belongs to shard (guild_id >> 22) % num_shards, both as Discord documents them.

Why (guild_id >> 22) and not guild_id % n?

The low 22 bits of a Snowflake are the worker id, process id and a per-process counter, which are not evenly spread: ids minted by one process in one millisecond differ only in the counter. Shifting them off leaves the creation time in milliseconds, which is effectively uniform across guilds, so the modulo spreads them evenly. It also means anyone can compute a guild’s shard from its id with no lookup.

A big bot restarts all 400 shards at once. What protects the guild tier?

Identify limits. Discord documents 1,000 identifies a day by default and a max_concurrency of shards that may identify in each 5-second window, so a large bot’s restart takes minutes by design. Each shard that resumes instead costs nothing. Intents help too: a bot that has not asked for presences or member updates is never sent them, which is why those intents are privileged.

How much memory does the replay buffer cost?

Most sessions get a few events a second, so a 20-second gap is a few kilobytes. Budget 100 KB per session for the bad case and 50,000 sessions is about 5 GB per node, which is fine. The cap is what matters: a session in a hyperactive guild that would pass 1 MB stops being resumable, which is the right trade against unbounded memory.

Sending is REST. Why not send messages over the socket?

REST gives every action the same auth, rate limits, idempotency through the nonce and error codes, and lets the edge reject abuse before it reaches the gateway. The gateway then scales on connections and outbound bytes only. The socket carries the few client frames that are about the session itself and are cheap to lose: heartbeats, presence, focus, member-list ranges, read acks and voice state.

How do you deploy the gateway without logging everyone out?

Drain a few percent of nodes at a time: stop placing new sessions on a node, move each session with its buffer to a successor, then send the client a reconnect so it resumes against the successor. The successor updates each guild with the session’s new node, which is far cheaper than a fresh subscribe. Only a crash forces identifies, and those are admitted at a controlled rate.

Messages: buckets, hot channels and coalescing

Where do trillions of messages live, and what happens when 30,000 people open the same channel in the same minute?

4 B messages a day is 46 k writes a second and about 440 TB a year before replication, read almost always as "the newest 50 in this channel" or "the 50 before this id". That is the shape wide-column stores are built for, and Discord has written about doing exactly this: Cassandra from 2017 with partitions of (channel_id, bucket) and buckets of about 10 days so partitions stayed under 100 MB, then ScyllaDB in 2022, going from 177 Cassandra nodes to 72 Scylla nodes. The partition key is not the hard part. The skew is.

Channels are wildly uneven. Most get a few messages a day; the busiest sustain 20 a second, ~520 MB a day, so a 10-day bucket for them would be a 5 GB partition. Reads are worse: one announcement in a guild with 100,000 online sends tens of thousands of identical reads at one partition within seconds, all for the same 50 rows. Discord’s 2023 write-up names hot partitions as the cause of latency that spread across its cluster, and its fix was a layer of data services that route by channel and coalesce identical reads.

  • Wide-column (channel_id, bucket) with per-channel bucket width, behind data services that route by channel and coalesce chosen
  • Partition by channel_id alone rejected
  • Cache of the newest messages per channel in front of the store situational: a handful of channels with sustained read storms; coalescing already covers bursts
  • Sharded relational database by channel_id situational: a team already operating sharded SQL at this size

The answer: Messages live in a ScyllaDB-style wide-column store keyed ((channel_id, bucket), message_id DESC), where bucket is the Snowflake’s millisecond timestamp divided by the channel’s bucket width. The default width is 10 days; a channel whose rate would put more than ~100 MB in a bucket gets a narrower one (1 hour for the 20-a-second channels, about 22 MB), recorded in channel metadata with the message id it applies from, so old rows never move. Every read and write goes through data services routed by consistent hashing on channel_id, so one instance sees all of a channel’s traffic and merges identical in-flight queries: the first request runs it and the rest wait for its rows. A per-channel list of non-empty buckets lets scroll-back skip gaps. Read states and mention counts are separate, because they change per user and per read while a message is written once.

The data service instance that owns a hot channel dies mid-storm.

The ring moves its channels to the next instance within a heartbeat. Waiters on in-flight queries get errors and retry, landing on the new owner, which starts coalescing them again. The store sees a short bump of uncoalesced reads, a few thousand, which three replicas absorb. Data services hold no durable state, so there is nothing to recover: they are a cache of work in flight.

How do edits and deletes work in an LSM store?

An edit rewrites the row under the same key, a newer version that wins on read. A delete writes a tombstone, and a channel where millions of messages are deleted leaves millions of tombstones that every read must skip. Discord described exactly this in 2017: a channel with millions of deleted messages became slow to load, and they cut tombstone lifetime from 10 days to 2 and started tracking empty buckets. I would also let bulk deletes drop whole buckets where they can.

Why not compute unread counts from the messages table?

It would be a count query per user per channel on every connect, across buckets, for 20 M concurrent users. Unread is a comparison: the channel’s last message id, which the guild keeps in memory, against the user’s last read id in read states. Mention counts are counters in read states fed from the event log, with @everyone as a short channel-level list. None of it touches the messages store.

How would you move trillions of messages to a new database without downtime?

Put the data services in front first, so the API no longer knows which database it talks to. Then write new messages to both, backfill history with a bulk migrator reading token ranges in parallel, verify by comparing sampled reads from both, and switch reads over. Discord described doing this in 2022 with a Rust migrator that moved up to 3.2 M messages a second and finished the copy in nine days.

How do you know coalescing is working?

Per data service instance, track requests in against queries out and the share of requests that joined an in-flight query, with the top channels broken out. In the store, track read rate and p99 per partition: a hot partition is one key far above the rest. Discord reported p99 history reads going from 40–125 ms on Cassandra to about 15 ms after the move, and that p99 is the number I would watch.

Permissions: role bitfields and channel overwrites

For every message and every recipient, who may see it? And what happens the instant a moderator takes a role away?

A large guild can have 250 roles, 500 channels and 1 M members, and every channel can override any role or member. Precomputing yes or no for every (member, channel) pair is 500 M entries for one guild, and one role edit could change millions of them. Yet permissions are checked constantly: on every send, on every history read, and for every recipient of every event, which is millions of checks a second.

Two properties make it cheap. Permissions are bitfields, so combining roles is OR and applying an overwrite is AND NOT then OR: a handful of instructions. And members with the same set of roles have the same permissions in every channel unless they have a member overwrite, so the number of distinct answers is the number of distinct role sets, a few thousand even in a huge guild, not the number of members.

  • Compute from bitfields on demand, cached per role set and channel, invalidated on change chosen
  • Precompute and store an ACL per (member, channel) rejected
  • Central permission service (Zanzibar-style) situational: permissions spanning many products or deep group nesting
  • Check only on send, and hide channels in the client rejected

The answer: Follow the documented algorithm. The owner gets everything; otherwise base is @everyone’s bits OR every one of the member’s roles, and ADMINISTRATOR means everything. In a channel apply the @everyone overwrite (deny, then allow), then all of the member’s role overwrites together (OR of denies, OR of allows, deny then allow), then the member’s own overwrite. No VIEW_CHANNEL implies no other channel permission, and a timeout leaves only VIEW_CHANNEL and READ_MESSAGE_HISTORY. The guild actor caches results per (role set, channel) plus per member override, so 3,000 role combinations across 500 channels is at most 1.5 M small entries, filled lazily. Checks run at send (SEND_MESSAGES, MENTION_EVERYONE, slowmode bypass), at history read (READ_MESSAGE_HISTORY) and at fan-out (VIEW_CHANNEL per recipient). A change invalidates what it touches: a role’s bits clear every role set containing it, an overwrite clears one channel, a member’s roles clear one member. The guild then tells each affected session which channels appeared or disappeared, in order with the message stream.

A moderator removes Alice’s role while a message in a channel it unlocked is being dispatched. Can she see it?

Both go through the guild actor in one order. If the role change is applied first, the fan-out check reads the new permissions and skips her. If the message was dispatched first, she got it while she could still see the channel, which is correct, and the next event tells her client the channel is gone so it stops showing it. History reads check again, so she cannot scroll back afterwards.

Doesn’t asking the guild on every send overload the biggest guilds?

It is one call per message, and the busiest guild sends maybe a few hundred messages a second; each check is a cached lookup of microseconds. That is tiny next to fan-out, which relays have already taken off the owner. The alternative, the API loading guild state itself, would put a second copy of every guild’s roles in the API tier and a race between the two copies.

What is slowmode and where is it enforced?

A per-channel minimum gap between one member’s messages, from seconds up to six hours in Discord’s API. The guild keeps the last send time per (channel, member) in memory, checks it in the same authorise call and returns a retry-after; members with manage permissions skip it. Losing it on a guild restart lets a few members post early once, which is acceptable. It sits beside the edge’s per-user and per-route limits: those protect the system, slowmode protects the conversation.

How do you test that the computation is right?

Property tests against a reference implementation of the documented algorithm, over generated guilds with random roles and overwrites, covering the edge cases: owner, administrator, timeouts, missing VIEW_CHANNEL, a deny on @everyone with an allow on a role. Then a shadow check in production: sample fan-out decisions, recompute them from scratch and alert on any disagreement. A permission bug is a privacy incident, so it deserves the rigour of money.

The owner edits @everyone in a 1 M-member guild. What does it cost?

Every role set changes, so the cache is cleared and refilled lazily as checks arrive, a few thousand role sets times the channels in use. The expensive part is telling sessions what changed: the guild recomputes visibility once per online role set and sends channel updates only to sessions whose visible channels changed, through the relays. It is a burst of seconds, rate limited per guild, and rare.

Presence: the biggest stream, mostly dropped

Presence changes outnumber messages. How do you show who is online and what they are playing without presence drowning everything else?

20 M concurrent users going idle, coming back, or starting and stopping games about once every ten minutes is 33 k changes a second. Each user shares guilds with maybe 5,000 online people, so broadcasting every change is ~170 M deliveries a second, about 35 times the message fan-out. In a 1 M-member guild a naive client would also download presence for 100,000 online members just to draw a sidebar that shows 40 of them.

Nobody needs most of it. A presence update matters only to someone looking at that person right now: in a member list on screen, a friends list, an open DM. And it is a hint: a status two seconds late or a game shown five seconds late hurts nobody. That permits coalescing per user, sending only to viewers, and dropping presence outright where nobody is looking.

  • Broadcast every change to every online member of every shared guild rejected
  • Coalesce per user, deliver only to sessions displaying the member, drop the rest chosen
  • Fetch presence on demand when a profile or list opens situational: rarely viewed surfaces, like a member search result or a mutual-servers list
  • Aggregate online counts only situational: guilds above a size threshold, alongside range subscriptions; the header count does not need to be exact

The answer: The presence service folds a user’s sessions into one status and activity list and emits at most one update per user every few seconds, so a game started and stopped within the window costs nothing. It sends the update straight to friends’ sessions and to each of the user’s guilds. Small guilds forward it to all online members, which is cheap. Large guilds forward it only to sessions whose subscribed member-list ranges contain that member, and above a size threshold drop everything else and show an approximate online count. Bots receive presence only with the privileged GUILD_PRESENCES intent, and big guilds send no offline members at identify (Discord’s large_threshold), both of which exist for exactly this load. Under pressure presence is the first thing shed. With about 50 real viewers per change, deliveries fall from ~170 M to ~1.7 M a second.

A user is online on desktop and idle on mobile. What do others see?

One status: online if any session is active, idle only if all are idle, and an explicitly chosen status such as do not disturb or invisible applies to every session. Activities are merged, so the desktop’s game shows. The aggregation happens once in the presence service, so guilds and friends get one update, not one per device.

The presence service crashes.

Messages keep flowing and statuses freeze. A replacement rebuilds state as gateways re-report each session’s status, which they do on a short interval, so presence is right again within a minute. Nothing durable is lost, because presence is derived from live sessions and was never stored. This is why it is separate from anything that carries messages.

How does the member list work with 100,000 online?

The guild keeps a sorted list of online members per visibility group: hoisted role groups first, then members by name. A client subscribes to the rows it shows, typically 0 to 99 plus wherever it has scrolled, gets a SYNC of those rows and then INSERT, UPDATE and DELETE operations at indexes. A member going idle is one row update for the few hundred sessions showing them. Channels with identical view permissions share one list, so a guild has a handful of lists, not 500.

Typing indicators in a channel with 5,000 people looking at it?

A typer sends at most one typing event every ~8 seconds; it goes only to sessions viewing that channel and is never stored. When more than a few people type at once the client shows "several people are typing", so the guild can send a capped sample instead of every typer. Like presence it is lossy by design and shed early under load.

How would you measure presence cost?

Deliveries per presence change by guild size, the share of updates dropped because nobody displayed the member, and gateway outbound bytes by event type. If presence’s share of bytes creeps up after a client release, the usual cause is a subscription leak: a client keeping ranges subscribed after scrolling away. Updates per user per minute also catch clients reporting activity changes too often.

Voice: regional SFUs beside the chat path

Why not carry voice over the same gateway connection, or connect people directly to each other? And which server hosts a call?

Voice is a different kind of traffic. It is a steady stream of small packets, 50 a second per speaker at 20 ms frames, where a late packet is worthless; the gateway is a reliable, ordered TCP stream where one lost packet stalls everything behind it. Three million people in voice at ~120 kbps each is ~360 Gbps of egress and about 225 M packets a second, more than all of chat, and conversation needs well under 200 ms mouth to ear, so the server must be near the people talking.

Voice needs almost nothing from the guild beyond who may join which channel. So the guild should decide admission and placement and then get out of the way: media flows between clients and a voice server, and the two fleets deploy and fail independently. How an SFU forwards and adapts per receiver is the Zoom question’s subject; Discord’s case is mostly audio and simpler.

  • Regional SFUs allocated by the guild per voice channel, signalling via the gateway, media over UDP chosen
  • Peer-to-peer mesh between participants rejected
  • Mixing server (MCU) rejected
  • Voice over the gateway WebSocket rejected

The answer: The guild owns voice state and allocates. On the first join of a voice channel it reads the healthy servers and their load for the region nearest the participants from discovery, picks the least loaded, and gives each joiner the endpoint and a short-lived signed token over the gateway. The client opens a signalling WebSocket to that server and sends encrypted Opus over UDP, only while speaking; Discord’s 2018 post describes Salsa20-based encryption on its native clients, with browsers on standard WebRTC. The server forwards each speaker’s packets to everyone else without decoding them. If it dies it drops out of discovery, clients report the broken socket over the gateway, the guild allocates another server and pushes the new endpoint, and audio is back in a second or two. Discord’s 2018 figures, about 2.6 M concurrent voice users on 850+ servers in 13 regions with over 220 Gbps of egress, are the same order as this design at 3 M.

Half the participants are in Europe and half in the US. Which region?

One server per call keeps it simple, so the region is a compromise: the first joiner’s, or the one that minimises the worst latency once several have joined. Moving a live call is possible (allocate in the new region, push new endpoints) but costs a glitch, so I would do it only when most participants are elsewhere. For a stage with thousands of listeners one server’s capacity is the limit, and listeners would be fed by relay servers that receive the speakers once, which is Zoom’s cascading idea.

How many voice servers do you need?

Bandwidth is not the limit; packets are. A listener receives ~1.5 streams, about 120 kbps but 75 packets a second, so 3 M listeners is ~225 M packets a second. A server forwarding a few hundred thousand packets a second means on the order of a thousand servers, consistent with Discord’s 2018 fleet of 850+ servers carrying 120 M packets a second. Size by packets per core, then add regional headroom so a server’s calls can fail over.

Why a signed token instead of the voice server asking the guild?

So admission needs no call back: the token names the user, guild, channel and server, expires in a minute, and is signed with a key the voice fleet trusts. A server can admit a thousand joins a second without touching the guild tier. Kicking someone is a rare message from the guild telling the server to drop that user.

Can you end-to-end encrypt voice through an SFU?

Yes, because an SFU forwards without decoding: clients encrypt each frame with keys the server never has, and the server routes on the unencrypted packet headers. The hard part is key agreement in a group whose membership changes, which needs a group key protocol so a new joiner cannot decrypt earlier audio. Discord announced end-to-end encryption for audio and video in 2024, its DAVE protocol built on MLS, which works this way.

How do you measure voice quality?

Clients report packet loss, jitter, round-trip time and jitter-buffer underruns per call, tagged by server and region. The p95 of those per server finds a bad host or a congested route within minutes, and the allocator can stop placing calls on servers whose recent quality is poor. On the servers, packets per second per core and NIC drops say when a region needs more capacity.

The theory behind it

  • Group chat and channel fan-out: Delivering messages to groups of every size: small groups versus huge channels, fan-out on write to inboxes versus reading from a channel log, membership storage, per-member read state and unread counts, read receipts, mentions, large group limits and notification throttling.
  • Presence and managing millions of connections: Holding millions of persistent WebSocket connections and showing who is online: connection gateways, routing messages to the right server, heartbeats, presence with TTLs, fan-out of status changes, typing indicators, reconnect storms and deploys.
  • Data modelling for Cassandra and DynamoDB: How to design tables for wide-column and key-value stores: query-first modelling, partition keys and sort keys, one table per query versus single-table design, time-bucketed partitions, secondary indexes, avoiding hot and huge partitions, and consistency options.

Related

Report an issue

Something wrong with this question? A number that does not add up, a step whose answer key looks off, a typo. It goes straight to the author with the page you are on.

Watch a strong candidate answer thisTurn by turn, with the design drawn as they talkWalk through →Mock interview