cf.completefrontend
My progress
SYSTEM DESIGN · #86

Design Discord

The web and desktop client of a community chat app like Discord: one person in dozens of servers, some with hundreds of thousands of members, text and voice channels side by side. The hard parts are one live connection that carries every server without drowning the client, a member list that shows a slice of a huge server and keeps it live, permissions computed on the client to shape the UI, voice that keeps running while you read, and rich messages full of custom emoji, embeds, and reactions.

The question

Design the client for a community chat product like Discord. People join servers. Each server has text channels, voice channels, roles, and a member list. Some servers are small groups of friends; others are public communities with hundreds of thousands of members and thousands of people online at once.

Walk me through the web and desktop client: how it stays connected to every server the user is in, how the member list works in a huge server, how the UI knows what the user is allowed to do, how voice channels work while the user keeps reading text, and how messages with custom emoji, embeds, and reactions render fast.

Real-life examples: Discord, Slack, Guilded, Revolt, Microsoft Teams

Requirements: what Discord adds to team chat

Discord looks like Slack with a dark theme. Under the same surface it is a different shape of problem. Slack is a workplace: a few workspaces, members who mostly know each other, and channels where most people can post. Discord is a set of communities: one person often sits in dozens of servers (up to 100, or 200 with Nitro, Discord's paid subscription), a few of them public servers with hundreds of thousands of members, and many of the channels have a voice room next to the text.

The core chat machinery (channels, the message list, reconnect and gap fetch, the outbox for sending, drafts, desktop notifications) is the same as in the Slack guide, and very fast chat is covered in the Live comments guide. This guide links to them and spends its time on what is specific to Discord: scale per server, the member list, roles and permissions, voice, rich content, and unread state across many servers.

So the first minutes go to questions about size and shape. The table shows the answers this guide assumes and what each one changes.

QuestionAnswer we assumeWhat it changes
How many servers per person?10 to 100, with a cap of 100 for normal accounts and 200 for Nitro subscribers.The client can never load every server in full. Most servers must be cheap to hold: a name, an icon, and unread state.
How big is a server?Most have under 50 members. Public ones reach 100,000 to over 1,000,000 members, with 10,000 or more online.Member data is loaded only for the slice on screen. Events from a huge server must be filtered before they reach the client.
How busy is a channel?A big server's main channel can get 10 or more messages a second at peak.The message list batches renders and leans on slowmode. Very fast chat reuses the Live comments ideas.
Who can do what?Roles with permissions, plus per-channel overrides for roles and single members.The client computes permissions to hide or disable UI. The server still checks every action.
Voice and video?Voice channels anyone can drop into, video, and screen sharing (Go Live).A second, separate connection for media. Voice must survive navigation between text channels.
Which platforms?A web app and a desktop app (Electron) from one codebase; mobile is out of scope.A long-lived process: memory must stay flat for days. The desktop app adds global push-to-talk keys.
Rich content?Custom emoji and stickers per server, link embeds, markdown, reactions.A message is parsed into a tree before render, and media dominates scroll performance.

Functional requirements

  • A server rail (the column of server icons) with unread pills and mention badges, a channel list per server grouped in categories, the message pane, and a member list sidebar.
  • Read and send messages with Discord markdown, custom emoji, stickers, attachments, replies, and threads.
  • A member list grouped by role and by online or offline, live as people come and go, in servers of any size.
  • Hide or disable every action the user is not allowed to take, per channel: sending, attaching files, managing messages, joining voice, speaking.
  • Join a voice channel, see who is in it and who is speaking, mute and deafen, use push-to-talk, turn on video, and share a screen. Keep the call running while browsing text channels.
  • Reactions with counts, including animated super reactions, and link embeds that appear after the message is sent.
  • Unread and mention state per channel and per server, with mute and notification settings at both levels, synced across devices.
  • Moderation surfaces: slowmode, timeouts, AutoMod blocks shown to the author, and the audit log.
  • Full keyboard use, a quick switcher (Ctrl+K), and screen reader support.

Non-functional requirements

  • Fast start: with a warm cache, the server rail and the last channel paint in under 1 second, before the live connection finishes its handshake.
  • Bounded traffic: the event rate the client must handle depends on what is on screen, not on the size of the servers it is in. A user in ten 500,000-member servers must not receive every presence change in them.
  • Instant feedback: a click in the member list or a mute toggle reacts within 100 ms; Interaction to Next Paint (INP, how fast the page reacts to input) stays under 200 ms.
  • Voice quality: speaking indicators within about 100 ms of the sound, and no audio gap when the user switches text channels.
  • Flat memory: a desktop client left open for a week stays in the same memory range as after the first hour.
  • Accessible: WCAG 2.2 AA, every feature by keyboard, reduced motion respected for animated emoji and reactions.

Out of scope

  • The media server (the SFU, a server that forwards each person's stream to the others) and codec tuning: the Video conferencing guide and the WebRTC lesson cover them.
  • Bots and slash command backends; the client only renders their messages and components.
  • Search, which works the same as in the Slack guide.
  • Mobile apps, payments for Nitro, and server discovery.
A wireframe of the Discord window. On the far left, the server rail with four servers: DM, PG, RS, and MJ; PG is selected. Next, the channel list for Pixel Guild: general, showcase, which is open, rules, and the voice channel lounge with Maya and Sam in it. At the bottom of the channel list, the voice bar: Voice: lounge, with mute and leave. In the middle, the showcase channel: Maya posts a new sketch with an image and reactions 12 and 3; Leo posts a link with an embed; the composer is at the bottom. On the right, the member list: a Mods group with Ana and Leo, an Online group of 24k with Maya, Sam, and Kai, then gray placeholder rows. The visible rows 0 to 99 are subscribed.discord.com/channels/8135/9244DMPGRSMJPixel Guild# general# showcase# ruleslounge (voice)MayaSamVoice: loungemute, leave# showcaseMaya: new sketch!123Leo: link belowembed: title, imageMessage #showcaseMods, 2AnaLeoOnline, 24kMayaSamKairows 0-99subscribed
Four regions plus the voice bar, each with its own data problem: the rail needs unread state for every server, the channel list needs permissions and voice states, the messages need rich rendering, and the member list needs a live window into a huge list.

Check your understanding · Exercise 1

Which answers from the interviewer change the design most compared with Slack? Select all that apply.

What makes Discord hard

Five traits drive every later decision. Each one is easy alone. Together they are why the Discord client is a hard design question.

  1. Lopsided scale. One user's servers range from 4 friends to 1,000,000 strangers. A design that loads "the server" works for the first and melts on the second. Every per-server structure (members, presence, typing, voice states) needs a plan for the huge case.
  2. Many servers on one connection. All servers share a single WebSocket (the gateway). If every server pushed every event, the busiest server would decide how much work the client does, even while the user looks at a quiet one.
  3. Rules that shape the UI. Whether the composer, the attach button, the delete option, or the voice join button appear depends on roles, their order, and per-channel overrides. The client has to compute this locally, fast, for every channel.
  4. Two kinds of real time. Text is ordered events over the gateway. Voice is a media stream over WebRTC to a different server. The two have different connections, failure modes, and lifetimes, but one UI.
  5. Heavy, long-lived pages. Messages are full of animated emoji, GIFs, images, and embeds. People keep the desktop app open for days. Memory and frame rate matter as much as network.

The math that rules out the naive client

Take one public server with 500,000 members and 25,000 of them online. People change presence (online, idle, do not disturb, offline, or start a game) all the time. Say each online member changes something once every 10 minutes on average: that is 25,000 / 600 seconds, about 42 presence events a second, from one server. Add typing events, member joins, and voice state changes and it passes 60.

A user in five such servers would get 300 events a second, each one parsed, put into a store, and possibly rendered. A laptop can parse that, but the battery and the main thread pay for it every second the app is open, for events about people the user will never see. And the initial payload is worse: 500,000 member objects at about 300 bytes each is about 150 MB for one server. No client can load that.

DataSmall server (40 members)Huge server (500,000 members)What the client does
Channels and roles10 channels, 5 roles300 channels, 200 rolesLoad in full: small even for huge servers, and needed for permissions.
Members40 members, a few KBAbout 150 MBLoad only the slice the member list shows, plus authors of visible messages.
PresenceA few events a minute40 or more events a secondReceive presence only for members the client is showing.
TypingRareSeveral a second in busy channelsReceive only for the open channel.
MessagesFew10 or more a second in the main channelReceive for the open channel; for others only enough to update unread state.

The table points at the main idea of the whole guide: the client subscribes to what is on screen. Small, structural data (channels, roles, the user's own membership) is loaded for every server, because the UI needs it to compute permissions and draw the channel list. Big, fast-changing data (members, presence, typing, message bodies) arrives only for the server, channel, and member range the user is looking at.

Think first

Why is it fine to load all 300 channels and 200 roles of a huge server, but not its members?

Check your understanding · Exercise 2

A user is in 8 servers, two of them with 300,000 members. What should decide how many events a second their client handles? Select all that apply.

One gateway connection for every server

The client needs live updates from every server the user is in. The transport is a WebSocket (see the WebSockets lesson; Slack and Chat app guides make the same pick). The real question is what that connection carries, and who decides.

Decision How should live updates from 60 servers reach one client?

One WebSocket per server

Open a socket for each server, each with its own heartbeat and reconnect.

Close sockets for servers the user is not looking at, or keep all open.

Pros

  • Each server is isolated: a flood in one cannot delay another.
  • Easy to reason about per server.

Cons

  • 60 sockets, 60 heartbeats, 60 TLS handshakes on start and after every network drop.
  • Browsers and proxies limit connections; mobile radios wake up for each heartbeat.
  • Events that span servers (DMs, friends, read state) need yet another socket.

One WebSocket, every event from every server

One connection; the server sends everything that happens in every server the user is in.

Pros

  • Simplest server logic: fan out to every member.
  • The client always has full data.

Cons

  • Traffic scales with the size of the user's servers, not with what they look at: the 300 events a second from the last chapter.
  • The initial payload of a huge server is too big to send at all.

One WebSocket with subscriptions

One connection carries all servers. Every server sends small structural events (channels, roles, the user's own membership) and compact unread updates.

The client tells the server what it is looking at: this server, this channel, this member range. Only then does it get messages, typing, and presence for it.

Pros

  • Traffic follows the screen. A user idle in 10 huge servers gets almost nothing from them.
  • One heartbeat, one reconnect, one place to resume.

Cons

  • The client must send subscription updates as the user navigates and scrolls.
  • Data for unsubscribed parts goes stale, so the client must treat it as stale and re-sync on subscribe.

What we pick: One connection with subscriptions. This matches what Discord describes publicly: a single gateway connection for all servers, and "passive sessions" where a server stops sending most events to members who are not looking at it. In the Maxjourney post, Discord says that turning off most fanout to passive sessions cut the cost of fanout by about 90 percent for very large servers.

The handshake, resume, and what READY holds

Discord's documented gateway protocol works like this, and it is a good model to reuse. The server first sends Hello (opcode 10) with a heartbeat_interval in milliseconds (the docs' example shows 45 seconds; clients commonly see about 41). The client sends Identify (opcode 2) with its token and gets back a READY event with a session_id and a resume_gateway_url. Every event after that carries a sequence number s.

What else READY holds differs by client. For bots, the docs list each server as an unavailable stub, and the server's data follows in its own GUILD_CREATE event. For the official client, community documentation describes a READY that already holds every server's small structural data (channels, roles, emoji, the user's own membership) plus read states. Either way, nothing big arrives in the first payload.

Heartbeats have two documented details worth copying. The first heartbeat waits a random fraction of the interval (jitter), so a million clients reconnecting after an outage do not all beat at once. And if the server has not acknowledged the previous heartbeat when the next one is due, the connection is a "zombie": the client closes it and resumes instead of waiting for the operating system to notice.

The bot side of the same protocol shows how Discord bounds traffic. In Identify, a bot sends intents, a bit mask of the event groups it wants, and presence and member events sit behind privileged intents that are off by default. A large_threshold (50 to 250 members) makes the gateway stop sending offline members of bigger servers. Each one is the rule this guide applies to the user client: send only what was asked for.

When the socket drops, the client reconnects to the resume_gateway_url and sends Resume (opcode 6) with the session_id and the last s it processed. The server replays what was missed. If the session cannot be resumed, it answers Invalid Session (opcode 9) and the client identifies again from scratch. This is the same gap-fetch idea the Slack guide covers, done by the gateway instead of by a REST call per channel.

A sequence diagram between the Stores, the Gateway client, and the Gateway. The Gateway sends Hello with a heartbeat interval of about 41 seconds. The Gateway client sends Identify. The Gateway replies READY with a session and the servers. The Gateway client paints the rail and channels from it. When the user opens a server, the Stores tell the Gateway client what is on screen: a channel and rows 0 to 99. The Gateway client sends a subscribe message, opcode 14. The Gateway replies with a member list sync. After a network drop, the Gateway client sends Resume with the last sequence number, and the Gateway replays the missed events.StoresGateway clientGatewayHello: heartbeat 41 sIdentify (op 2)READY: session, serverspaint rail, channelson screen: rows 0-99subscribe (op 14)member list SYNCafter drop: Resume, seqreplay missed events
READY carries only small structural data for every server. Big data, like the member list, flows only after the client says what is on screen, and a resume replays from the last sequence number instead of starting over.

Active and passive servers

Each server is in one of two modes for this client. A passive server is one the user is not looking at. Discord's engineering posts describe passive sessions: the server stops sending most events and instead sends a periodic compact update with the server's channels (each with its latest message id), members, and who is in voice.

That is enough to draw unread pills and voice rooms. Mentions of the user cannot wait for the next periodic update, so in this design they arrive as their own small event. An active server is the one on screen. When the user opens it, the client subscribes and starts receiving full messages, typing, and member list updates for the open channel.

The official client does this with an opcode that is not in Discord's public bot documentation. Community documentation (unofficial "lazy guilds" docs by people who reimplement the protocol) calls it opcode 14: the client sends the server id and, per channel, the member list ranges it wants, such as [[0, 99]]. Newer community docs mark opcode 14 as deprecated in favor of a bulk version (opcode 37) that updates several servers in one message. Treat the shape as unofficial: what matters is a subscription message that says what is on screen.

ts
type Range = [number, number];
type Wanted = { guildId: string; channelId: string; ranges: Range[] };

let sent: Wanted | null = null;
let pending: Wanted | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;

export function setOnScreen(next: Wanted) {
  pending = next;
  if (timer) return;
  timer = setTimeout(flush, 250);
}

function flush() {
  timer = null;
  if (!pending || sameAs(pending, sent)) return;
  gateway.send({
    op: 14,
    d: { guild_id: pending.guildId, typing: true, activities: true,
         channels: { [pending.channelId]: pending.ranges } },
  });
  sent = pending;
}
A subscription manager: one place that turns 'what is on screen' into gateway messages, sent at most every 250 ms.

Two details make it work. Batching: scrolling the member list fires scroll events 60 times a second, but the server should hear about a new range at most a few times a second. Re-subscribe on resume: after a reconnect the server may have dropped the subscription, so the client sends its current sent value again as soon as the session is back.

Keeping bytes down

Discord's gateway compresses its traffic as one continuous stream, chosen with a compress parameter: zlib-stream or zstd-stream. The client keeps one decompression context for the life of the connection. With zlib it buffers incoming bytes until the 4-byte flush suffix 00 00 ff ff and then inflates them. Because the context remembers earlier data, repeated field names and ids across events compress well.

Discord wrote in 2024 that switching its apps from zlib to Zstandard, together with sending passive sessions only what changed instead of full snapshots, cut gateway bandwidth by almost 40 percent; the post describes native Zstandard bindings in the desktop and mobile apps. For the client, the lesson is that decompression runs on every event. It must be a streaming decoder fed frame by frame, never a fresh decode of each frame from zero.

Check your understanding · Exercise 3

The user switches from server A to server B. What should the client do? Select all that apply.

The member list: a live window into a million people

The member list is the column on the right of a text channel. It shows people grouped by role (for roles the admins chose to display separately, called hoisted roles), then everyone else who is online, then offline members. Each row has an avatar, a name in the color of the person's highest colored role, a status dot, and maybe a custom status or the game they are playing.

In a server with 20 people this is trivial. In a server with 500,000 it is one of the hardest parts of the client. The Slack guide already subscribes to presence only for avatars on screen; Discord goes a step further and lets the server own the order of the whole list.

The member list as one flat list of items, split into ranges of 100. Range 0 to 99 is held. Range 100 to 199 is not held and shows placeholders. Far below, range 52,000 to 52,099 holds the viewport, and range 52,100 to 52,199 is held as lookahead. Range 52,200 and on is not held. Zoomed in, range 0 to 99 holds item 0, the header Mods, 12; items 1 and 2, Ana and Leo; item 13, the header Online, 24,301; and item 14, Maya. A note says the viewport shows rows 52,010 to 52,030 and the held ranges are 0 to 99 and 52,000 to 52,199.100 items per range0-99100-199...52000-5209952100-5219952200-...placeholders until SYNC0header: Mods, 121Ana2Leo13header: Online, 24,30114Mayaviewport: rows 52,010 to 52,030held: 0-99 and 52000-52199
Headers and members share one index space, so a range is just a slice of it. The client holds the top range, the viewport's range, and one range of lookahead; everything else is a placeholder of the right height.

Decision Where is the member list sorted and grouped, and how does the client get the part it shows?

Load every member, sort on the client

Fetch all members of the server, keep presence for all of them, group and sort locally.

Pros

  • Simple: one array, one sort, instant scroll anywhere.
  • Works offline once loaded.

Cons

  • About 150 MB and 40 or more presence events a second for a 500,000-member server.
  • A re-sort on every status change is O(n log n) on the main thread.

Page members over REST

Fetch 100 members at a time by offset as the user scrolls: GET /members?offset=200&limit=100.

Pros

  • Small payloads; any HTTP cache helps.

Cons

  • The order changes all the time as people go online and offline, so offsets shift between requests: rows repeat or go missing.
  • No live updates: status dots go stale the moment the page arrives.

Server-sorted list with range subscriptions

The server keeps each channel's member list sorted and grouped.

The client subscribes to index ranges, like rows 0 to 99, and gets a full sync of those rows, then small operations: insert at index, update at index, delete at index.

The server also sends the count of each group, so the client knows the full height of the list.

Pros

  • Payload and event rate follow the visible rows, whatever the server size.
  • Order is always the server's order, so no shifting offsets.
  • The client never sorts; it applies operations.

Cons

  • The client depends on a live connection to scroll; unsubscribed rows show as placeholders for a moment.
  • More server logic, and a protocol the client must apply in exact order.

What we pick: Server-sorted ranges. It is the only option whose cost does not grow with the server. Community documentation of the official client describes exactly this: a GUILD_MEMBER_LIST_UPDATE event with operations named SYNC, INSERT, UPDATE, DELETE, and INVALIDATE, against ranges of 100 rows, with group counts.

One flat list of headers and rows

The trick that makes ranges simple: the server flattens groups and members into one list of items. Item 0 is the header "Moderators, 12", items 1 to 12 are moderators, item 13 is the header "Online, 24,301", and so on. A range like [100, 199] is just items 100 to 199, whatever mix of headers and people that is. The client keeps a sparse array of items by index and renders it with a virtualized list (see the Virtualization lesson and the Virtualized list guide).

Which list? A member list depends on who can see the channel. Two channels with the same view rules have the same list, so the community docs describe a list id derived from the channel's view permissions: channels anyone can see share one list called everyone, and private channels get a hash of their allow and deny rules. The client keys its cache by that list id, so moving between 30 public channels reuses one list instead of 30.

ts
type Item = { group: { id: string; count: number } } | { member: Member };
type Op =
  | { op: "SYNC"; range: [number, number]; items: Item[] }
  | { op: "INSERT" | "UPDATE"; index: number; item: Item }
  | { op: "DELETE"; index: number }
  | { op: "INVALIDATE"; range: [number, number] };

export function applyOps(list: (Item | undefined)[], ops: Op[]) {
  for (const o of ops) {
    if (o.op === "SYNC") o.items.forEach((it, i) => (list[o.range[0] + i] = it));
    else if (o.op === "INSERT") list.splice(o.index, 0, o.item);
    else if (o.op === "UPDATE") list[o.index] = o.item;
    else if (o.op === "DELETE") list.splice(o.index, 1);
    else for (let i = o.range[0]; i <= o.range[1]; i++) list[i] = undefined;
  }
}
Applying list operations in order. Items are a sparse array; unknown indexes render as placeholders.

Ops must be applied in the order received, because each index assumes the previous op already ran. A person going online is two ops: DELETE at their old offline index and INSERT at their place in the online group. Applied out of order, every row after them shifts by one.

Scrolling: which ranges to hold

  • Rows are a fixed height (about 44 px for a member, a bit less for a header), so the total height is the item count times the row height, known from the group counts before any row loads. The scrollbar is honest from the first paint.
  • On scroll, compute the visible item indexes, round out to the 100-row ranges they touch, and add one range of lookahead in the scroll direction. Keep range [0, 99] always: it holds the top groups people jump back to.
  • Hold at most about 3 ranges. When the user flings to row 40,000, the client drops the old ranges (the server stops sending ops for them) and subscribes to the new ones. Until the SYNC arrives, rows show gray placeholder bars, not a spinner over the whole list.
  • Keep the scroll position stable when rows are inserted above the viewport: measure the first visible item before applying ops and restore its offset after.

Presence: throttled on the server, batched on the client

Even inside 300 subscribed rows, a busy server changes status often. Two layers keep this cheap. The server can batch: collect operations for a range for a short window and send one event. The client batches too, with the same per-frame flush as the Slack guide: incoming ops go into a queue, and one requestAnimationFrame callback applies them all and renders once, so a person who flips online, idle, online inside one batch shows only the end state.

Some updates are worth less than others. A status dot change is cheap to render, but a row moving between groups shifts everything below it, which looks jumpy if it happens every second. A reasonable rule: apply in-place updates at once, and if more than a few moves land in the visible area within a second, apply them together. The Presence and cursors lesson covers the same idea of trading freshness for calm.

Rows 0 to 17 visible. Held ranges: 0-99, 100-199.

A member list of 100,003 items. Visible rows start at 0. Held ranges: 0, 100. 0 ranges wait for a sync. 34 rows are in the DOM.whole listyoufirst 8 visible rowsMods, 12member 1member 2member 3member 4member 5member 6member 7held: 2 rangesDOM rows: 34placeholders: 0press Presenceburst to see theserver filterof 100,003 items
Rows 0 to 17 visible. Held ranges: 0-99, 100-199.

Check your understanding · Exercise 4

A user drags the member list scrollbar from the top to row 52,000 of a huge server. What should happen? Select all that apply.

Permissions in the UI: roles, overwrites, and bit math

In Discord, what a person can do depends on three things: the roles they have in the server, the order of those roles (the role hierarchy), and per-channel overwrites that allow or deny single permissions for a role or a single member. The same person can post in #general, only read #announcements, and not even see #mod-chat.

The server checks every action; the client can never be trusted with security. But the UI still needs to know the answer, constantly: whether to show the composer or a "you cannot send messages here" note, whether the attach button exists, whether "Delete message" appears on other people's messages, whether the voice channel can be joined. A user who clicks a button only to get an error has been let down by the UI.

Decision How does the UI know what the user may do in each channel?

Ask the server when the user tries

Show every button; on click, call the API and show the error if it fails.

Pros

  • No permission logic on the client at all.

Cons

  • Buttons that always fail. A composer that lets you type a long message and then rejects it.
  • Hidden channels would have to be sent to the client to be listed, leaking their names.

Server sends computed permissions per channel

With each channel, the server includes the user's final permission bits.

Pros

  • The client just reads a number.
  • One source of truth for the algorithm.

Cons

  • Any role change means re-sending the bits for every channel of the server, for every member who has that role.
  • Admins previewing "View server as role" need a server round trip per preview.

Client computes from roles and overwrites

The client already has the server's roles, each channel's overwrites, and the user's own role ids from READY.

It runs the same documented algorithm the server runs, caches the result per channel, and recomputes when an input changes.

Pros

  • Instant, offline-safe, and cheap: a few dozen bit operations per channel.
  • Role edits arrive as one small event and every channel updates.
  • Previews like "View server as role" are just the same function with different inputs.

Cons

  • The algorithm must match the server's exactly, or the UI lies. A shared test suite with fixtures from the server team keeps them in step.

What we pick: Compute on the client, enforce on the server. Discord documents the algorithm publicly for bot developers, which is a strong hint that clients are expected to compute it.

Permissions are bits in a big number

Each permission is one bit. A role's permissions are one number with the bits it grants set to 1. In Discord's documented list, VIEW_CHANNEL is bit 10 (0x400), SEND_MESSAGES bit 11 (0x800), MANAGE_MESSAGES bit 13, ATTACH_FILES bit 15 (0x8000), ADD_REACTIONS bit 6 (0x40), CONNECT bit 20, SPEAK bit 21, and ADMINISTRATOR bit 3. There are more than 40 permissions, and some sit above bit 31 (MODERATE_MEMBERS, for timeouts, is bit 40).

The algorithm, step by step

  1. Owner: the server owner has every permission. Stop.
  2. Base: start from the @everyone role's permissions, then OR in the permissions of each role the member has.
  3. Administrator: if the base has ADMINISTRATOR, the member has every permission in every channel. Stop.
  4. @everyone overwrite for this channel, if any: clear its denied bits, then set its allowed bits.
  5. Role overwrites: for all of the member's roles that have an overwrite here, OR together all their denies and all their allows. Clear the combined denies, then set the combined allows. So if one role denies a bit and another allows it, allow wins.
  6. Member overwrite for this person, if any: clear its denies, then set its allows. This one wins over everything above.
  7. Implicit rules: without VIEW_CHANNEL, every other permission in the channel is off. Without SEND_MESSAGES, the permissions that only make sense when sending (ATTACH_FILES, EMBED_LINKS, MENTION_EVERYONE, text to speech) are off too. A member in a timeout keeps only VIEW_CHANNEL and READ_MESSAGE_HISTORY (owners and administrators cannot be timed out).
The permission algorithm as five steps, with Maya's value in the showcase channel after each. Base, OR of everyone and each role: 0x308C40. Everyone overwrite, clear deny then set allow: 0x308400. Role overwrites, all denies then all allows: 0x308C00. Member overwrite, applied last: 0x300C00. Implicit rules, no view means zero and no send trims sending permissions: 0x300C00. Two notes: owner or administrator gets every bit and stops early; a timed out member keeps only view and read history. The result: send on, attach off, react off, voice on.base0x308C40@everyone0x308400roles0x308C00member0x300C00implicit0x300C00OR all rolesand ~denyor allowmerge, thenallow winsapplied lastno VIEW: 0no SEND: trimowner or ADMINISTRATOR:every bit, stop earlytimed out: keeps onlyview, read historyMaya in #showcasesend: onattach: offreact: offvoice: on
Each step is two bit operations: p = (p & ~deny) | allow. Order is the whole trick: role overwrites merge before they apply, so an allow on any role wins, and the member overwrite comes last.
ts
const ALL = (1n << 64n) - 1n;
const ADMIN = 1n << 3n, VIEW = 1n << 10n, SEND = 1n << 11n;
// Text to speech, embed links, attach files, mention everyone
const SEND_DEPENDENT = (1n << 12n) | (1n << 14n) | (1n << 15n) | (1n << 17n);
const TIMEOUT_KEEP = VIEW | (1n << 16n); // view, read message history

export function channelPerms(
  g: Guild, roles: Record<string, Role>, me: Member, ch: Channel,
): bigint {
  if (g.ownerId === me.userId) return ALL;
  let p = roles[g.id].permissions; // @everyone role id equals the guild id
  for (const r of me.roleIds) p |= roles[r].permissions;
  if (p & ADMIN) return ALL;

  const ow = ch.overwrites;
  const every = ow[g.id];
  if (every) p = (p & ~every.deny) | every.allow;
  let allow = 0n, deny = 0n;
  for (const r of me.roleIds) {
    if (ow[r]) { allow |= ow[r].allow; deny |= ow[r].deny; }
  }
  p = (p & ~deny) | allow;
  const mine = ow[me.userId];
  if (mine) p = (p & ~mine.deny) | mine.allow;

  if (!(p & VIEW)) return 0n;
  if (!(p & SEND)) p &= ~SEND_DEPENDENT;
  return (me.timedOutUntil ?? 0) > Date.now() ? p & TIMEOUT_KEEP : p;
}
The documented algorithm with BigInt. Roles and overwrites hold BigInt fields parsed from strings.

A worked example

Maya is in a server with roles Member and Artist. The @everyone role grants view, send, add reactions, connect, and speak: 0x400 + 0x800 + 0x40 + 0x100000 + 0x200000 = 0x300C40. Member adds ATTACH_FILES (0x8000), so her base is 0x308C40. Now the #showcase channel has three overwrites.

StepOverwriteResult (hex)What changed
Basenone0x308C40View, send, react, attach, connect, speak.
@everyonedeny SEND_MESSAGES, deny ADD_REACTIONS0x308400Send and react are cleared: a read-only channel for most people.
RolesArtist allows SEND_MESSAGES0x308C00Artists may post. Reactions stay off.
MemberMaya: deny ATTACH_FILES0x300C00A moderator stopped Maya from uploading here.
Implicithas view and send0x300C00Nothing more to clear.

So in #showcase Maya sees the composer (send is on), no attach button (attach is off), no add-reaction button on hover (reactions are off), and could still join a voice channel with the same overwrites. Toggle the same inputs in the lab below and watch each step.

Maya's roles
#showcase overwrites

Final bits 0x300C00. Each step is (bits AND NOT deny) OR allow.

Maya's permissions in the showcase channel, step by step: base 0x308C40, @everyone 0x308400, roles 0x308C00, member 0x300C00, implicit 0x300C00. UI: see channel on, composer on, attach off, react off, delete others off, join voice on.base0x308C40@everyone0x308400roles0x308C00member0x300C00implicit0x300C00What the UI showssee channel: oncomposer: onattach: offreact: offdelete others: offjoin voice: onhighlighted steps changed the bits
Final bits 0x300C00. Each step is (bits AND NOT deny) OR allow.

Role hierarchy: who can act on whom

Roles have a position. Moderation actions follow it: a member can kick, ban, time out, or change the nickname of someone only if their own highest role is above that person's highest role, and they can only grant or edit roles below their own highest role.

The client uses this to decide the context menu on a member: "Kick" appears only when the user has KICK_MEMBERS and outranks the target. The role list in settings grays out roles the user cannot drag. Name colors come from the same order: the highest role that has a color wins.

Hide, disable, or explain

Missing permissionUI choiceWhy
VIEW_CHANNELHide the channel from the list.The user should not even learn its name.
SEND_MESSAGESReplace the composer with a note: "You do not have permission to send messages in this channel."The channel is meant to be read. A missing composer with no reason is confusing.
ATTACH_FILES, ADD_REACTIONSHide the button.A permanent, per-channel rule. A disabled button adds noise.
Timeout or slowmodeDisable the composer and show the time left.Temporary. The user needs to know when it ends.
CONNECT on a voice channelShow a lock icon; on click, explain.The channel is visible but locked, a common pattern for staff rooms.

Cache results per channel in a permission store, keyed by channel id. Invalidate the whole server's cache on a role update, the user's own member update, or a channel update; that is rare, and recomputing 300 channels takes well under a millisecond.

Hiding a channel only protects its name if the name never reached the device. Discord announced in August 2026 that users stop receiving full data for channels they cannot view: over the gateway such a channel arrives with its name replaced by ___hidden___ and a CHANNEL_OBFUSCATED flag, and a CHANNEL_UPDATE with the real data follows when access is granted. Its overwrites are reduced to one that denies VIEW_CHANNEL to @everyone, so the same permission function hides it with no special case.

Check your understanding · Exercise 5

In a channel, @everyone is denied SEND_MESSAGES. Role Helper denies SEND_MESSAGES and role Mentor allows it. Sam has both roles and no member overwrite. Which are true? Select all that apply.

Voice and video channels

A voice channel in Discord is a room you drop into by clicking it. There is no ringing and no meeting link. People stay for hours, often while reading and typing in text channels of the same or another server. That changes three things compared with a meeting app: the call has to outlive navigation, the channel list must show who is in each voice room without joining, and the media connection is separate from the text gateway.

This chapter covers the client side of voice. How media reaches the others (a selective forwarding unit, codecs, bandwidth estimation, simulcast) is in the WebRTC lesson and the Video conferencing guide, and works the same here.

Two connections: the gateway says where, the voice server carries sound

Discord's documented flow for voice uses both connections. The client sends a voice state update (opcode 4) on the main gateway with the server id, the channel id, and its own mute and deafen flags. The gateway answers with two events: VOICE_STATE_UPDATE (the user is now in that channel, with a voice session_id) and VOICE_SERVER_UPDATE (a token and an endpoint: the voice server for this call).

The client then opens a second WebSocket to that endpoint, identifies with the token, and sets up the media connection. Discord has written that its browser app uses the browser's WebRTC, while the desktop and mobile apps share one C++ media engine built on the native WebRTC library. Because every client talks only to Discord's media servers, the native engine skips the offer and answer exchange (SDP) and the path-finding step (ICE, which tries network routes until one works); the browser keeps them. This guide follows the browser path.

Decision Why not send voice signaling over the main gateway?

Everything over the main gateway

Send offers, answers, speaking events, and media control over the same socket as chat.

Pros

  • One connection to manage.

Cons

  • The main gateway is busy with chat events; a burst of messages delays a speaking event.
  • Voice servers are picked by region and load per call; the gateway would have to proxy to them.
  • A gateway reconnect would interrupt the call.

A separate voice connection per call

The gateway only says which voice server to use. Signaling and speaking events go over a socket to that server; media over WebRTC.

Pros

  • Voice keeps working through a main gateway reconnect.
  • The voice server is close to the people in the call, so signaling is fast.
  • Each part scales on its own.

Cons

  • Two connections and two failure paths to show in the UI.

What we pick: A separate voice connection. The UI shows its state on its own (the voice bar's connection quality), apart from the main connection's offline banner.

A sequence diagram for joining voice, between the UI, the Voice service, the Gateway, and the Voice server. The user clicks lounge in the UI; the UI tells the Voice service. The Voice service sends a voice state update, opcode 4, on the Gateway. The Gateway replies with VOICE_STATE_UPDATE and then VOICE_SERVER_UPDATE with a token and an endpoint. The Voice service connects to the Voice server and identifies, then sends a WebRTC offer and gathers ICE candidates. ICE fails behind a strict firewall, and the voice bar shows No route. The Voice service retries with an ICE restart, and the Voice server connects; the voice bar turns green.UIVoice serviceGatewayVoice serverclick loungevoice state (op 4)VOICE_STATEVOICE_SERVERconnect, identifyWebRTC offer, ICEICE failedbar: No routeICE restart: ok
The gateway only says where to go; the call itself runs on a second connection to a voice server. The failure path shows up in the voice bar, not as a blocking error: the user can keep chatting while voice retries.

The voice connection as a state machine

A voice connection goes through several steps, and each can fail. Modeling it as a state machine keeps the UI honest: "Connecting", "RTC connecting", "Voice connected", "No route" are each a state with its own label in the voice bar. Discord's own voice bar shows labels like these.

The voice connection state machine. Idle goes to joining, with opcode 4 sent, when the user clicks a channel. Joining goes to voice socket, which identifies, when the server update arrives. Voice socket goes to RTC connecting, doing ICE and DTLS, when ready. RTC connecting goes to connected when ICE succeeds. Connected goes back to RTC connecting on a network change. RTC connecting goes to no route when ICE fails. No route goes back to joining on retry, or to idle when the user gives up.idlejoiningop 4 sentvoice socketidentifyno routeretry or leaveconnectedgreen barRTC connectingICE, DTLSclickserver updatereadyICE oknet changeICE failedleaveretry
Each state is a label in the voice bar. A network change does not drop the call to idle: it goes back to RTC connecting and recovers, while the user stays in the channel.
  • Moved by a moderator or channel deleted: the gateway sends a new VOICE_STATE_UPDATE with a different channel or none. The machine goes to the new channel or to idle without the user doing anything.
  • Voice server change (the server moved the call to another region or machine): a new VOICE_SERVER_UPDATE arrives; the client closes the old voice socket and connects to the new endpoint, keeping the local microphone track.
  • Network change (Wi-Fi to Ethernet): WebRTC's ICE restart finds a new path; the voice bar shows "RTC connecting" for a moment, then recovers.

Who is here, who is speaking

Under each voice channel in the channel list, the client shows who is in it, with mute and deafen icons. That data comes from VOICE_STATE_UPDATE events for the whole server, which is small (people in voice are a few percent of members) and needed even in passive servers, so people can see where their friends are.

The speaking indicator is the green ring around an avatar. The signals are the ones the Video conferencing guide uses: a Web Audio AnalyserNode on the user's own microphone, and RTCRtpReceiver.getSynchronizationSources(), which gives an audioLevel from 0 to 1 for each incoming audio source. The voice server's speaking messages tell the client which user each audio source (its SSRC, the stream id carried in every packet) belongs to.

What Discord adds is how many avatars show the ring: everyone in the channel list's voice rooms, not just a call grid. Poll levels every 100 ms and flip a per-user boolean only when it crosses a threshold. That keeps renders rare: one when someone starts speaking and one when they stop, not 10 a second.

StateWho sets itWhat the client doesIcon
Self muteThe userSets the microphone track's enabled to false; tells the gateway self_mute: true.Crossed microphone
Self deafenThe userMutes all incoming audio elements and also self mutes.Crossed headphones
Server mute or deafenA moderator with MUTE_MEMBERS or DEAFEN_MEMBERSThe user cannot unmute themselves; the toggle is disabled with a reason.The same icons in red
Push-to-talkThe user's setting, or required when they lack USE_VADSends audio only while the key is held.None while silent

Push-to-talk shows a platform limit. In a browser, the page only hears keydown and keyup while it has focus, so push-to-talk stops working when the user is in another window; Discord's web app labels its push-to-talk as limited for this reason. The desktop app registers a global key hook through the native shell, so the key works while the user plays a game. The design point: put the key source behind an interface (onTalkStart, onTalkEnd) with a browser implementation and a desktop one.

Video and Go Live

Camera video in a voice channel is a video track on the same WebRTC connection. Screen sharing, called Go Live in a server, uses getDisplayMedia() and is treated differently: viewers choose to watch with a "Watch stream" button instead of receiving it automatically. That opt-in matters at scale: a stream to 3 viewers costs the server's fanout only for those 3.

Going live needs the STREAM permission (called "Video" in the settings UI). Higher resolutions and frame rates are a Nitro perk, so the quality picker disables those options with a Nitro badge for others.

End-to-end encrypted calls (DAVE)

Since September 2024, Discord has been moving voice and video in DMs, group DMs, voice channels, and Go Live streams to end-to-end encryption with its DAVE protocol, and its developer docs say only end-to-end encrypted calls are supported from March 1, 2026. Members of a call agree on keys through a Messaging Layer Security group (MLS, RFC 9420), with the voice gateway delivering the messages. Each sender encrypts its frames with its own key, so the media server forwards packets it cannot read.

Three things change in the client. In the browser, encryption runs in WebRTC's encoded transform API, a hook that sees each encoded frame after the encoder and before the network. Each join or leave starts a new key epoch, which the voice service must apply without a gap in audio. And the UI gains a Voice Privacy Code, a number derived from the current keys that members can compare out of band to check that nobody is impersonated.

The persistent voice bar

The call must survive the user clicking a text channel in another server. So the voice connection cannot live inside a route component, which would unmount on navigation. It lives in a voice service created once at app start, outside the React tree, with a small store the UI reads: the channel, the state, the ping, and the mute flags.

The voice bar at the bottom of the channel list is a view of that store, shown on every route, with disconnect, mute, deafen, and the call's server and channel name as a link back. When the user leaves the voice channel's text view while watching a stream, the video moves into a small floating picture-in-picture window.

Check your understanding · Exercise 6

A user is in a voice channel and clicks through five text channels in two other servers. Which design choices keep the call working and the UI correct? Select all that apply.

Rich messages: markdown, custom emoji, embeds, and reactions

A Discord message is a short string that can expand into a lot: formatting, mentions that need names and colors from stores, custom emoji images, a spoiler that hides text, a highlighted code block, link embeds that arrive a second later, and a row of reactions. A busy channel shows 50 of these on screen and scrolls through thousands. Rendering them correctly and cheaply is most of the message list's cost.

What is in the string

The text uses Discord's markdown: **bold**, *italic*, __underline__, ~~strike~~, ||spoiler||, inline code in single backticks, fenced code blocks with a language name (three backticks then js), > quotes, # headings, -# small subtext, and masked links [text](url). Rich references are tokens with ids, not names, so renames never break old messages.

TokenMeaningRendered from
<@80351110224678912>Mention a userMember store: display name and role color in this server.
<@&41771983423143936>Mention a roleRole store: role name and color; highlighted if the user has the role.
<#41771983423143937>Link to a channelChannel store; shown as "no access" if the user lacks VIEW_CHANNEL.
<:blobwave:396521773144866826>Custom emojiCDN image by id; the name is the alt text.
<a:partyparrot:396521773144866827>Animated custom emojiAnimated image by id.
<t:1767225600:R>TimestampFormatted in the reader's locale and time zone; the R style is relative, like "in 3 days".

Decision How should message text become what the user sees?

Markdown to an HTML string, then innerHTML

Run a markdown library that outputs HTML; set innerHTML on the message element.

Pros

  • Short to write; fast for static text.

Cons

  • One parser bug or unescaped character is stored XSS shown to every member of a 500,000-person server.
  • Mentions, emoji, and spoilers need live data and click handlers, which an HTML string cannot hold.

Server renders HTML

The server stores or returns rendered HTML for each message.

Pros

  • One parser; clients just display.

Cons

  • Mentions depend on the reader (names, colors, highlights, access), so the server would render per reader.
  • Same innerHTML risk, now in every client.

Parse to a tree on the client, render React elements

A rule-based parser turns the string into a small tree of nodes: text, bold, spoiler, code, mention, emoji, link.

A renderer maps each node to a component that reads stores for names, colors, and access.

Pros

  • No HTML is ever built from user text, so React escapes everything.
  • Each node is a real component with handlers and accessible markup.
  • The tree can be cached per message id and content.

Cons

  • Parsing costs CPU: cache the tree, and parse lazily for messages scrolled into view.

What we pick: Parse to a tree and render elements. It is the only option that is safe by construction and lets mentions depend on the reader. The XSS lesson explains why building HTML from user text is the root of most chat app XSS.

How a message renders. The content string goes to the parser, which produces a node tree cached by message id, which goes to the renderer. For the example string with bold gm, a user mention, a custom emoji, and a spoiler, the tree has four nodes: bold gm, mention 8035, emoji 3965, and spoiler. The bold node becomes a strong element with escaped text. The mention reads the member store for the name and role color. The emoji loads a 48 pixel image from the emoji CDN. The spoiler becomes a button that hides its text until clicked.contentraw stringparserrulesnode treecached by idrenderercomponents**gm** <@8035> <:blob:3965> ||boo||bold: gmmention 8035emoji 3965spoilerstrongescaped textMember storename, colorEmoji CDN48 px imageSpoilerbutton, hiddenno HTML strings: React escapes all text
No step builds HTML from the user's text, so there is nothing to inject. Names, colors, and access come from stores at render time, which is why the same message can look different to different readers.
  • Masked links can lie: [docs](https://evil.example). Show the real host on hover and confirm before leaving for an untrusted domain. Allow only https: and http: URLs; never javascript:.
  • Spoilers render as a covered button labelled "Spoiler". Click or Enter reveals it for that message only. Images marked as spoilers are blurred the same way. A screen reader hears "Spoiler, button" rather than the hidden text.
  • Code blocks need syntax highlighting, which is heavy. Load the highlighter on first use, only for the languages seen, and highlight blocks over a few KB off the main thread in a worker, showing plain monospace text until it is ready.

Custom emoji and stickers

Each server has its own emoji and stickers: 50 static and 50 animated emoji slots by default, with more as the server unlocks boost levels. Emoji images come from a CDN by id, in the size the UI needs: about 22 px inline and 48 px when a message is only emoji ("jumbo"), so one 48 px file serves both. Stickers are bigger images, and some are Lottie animations (vector animation described as JSON), drawn by a player loaded only when the first Lottie sticker appears.

Nitro gating is a UI decision driven by data. Without Nitro, a user can use a server's custom emoji only in that server, and cannot use animated emoji. The emoji picker still lists other servers' emoji, grayed out with a lock and an upsell, because seeing them is part of the product.

The picker needs every emoji of every server the user is in: a few thousand entries, loaded from READY data and searched in memory. A channel can also deny USE_EXTERNAL_EMOJIS, which locks emoji from other servers for everyone in it.

Animated emoji are the main memory and CPU cost in a long session. A channel full of animated emoji can have 200 GIFs decoding at once. So: animate only emoji in the viewport (an IntersectionObserver swaps the animated source for a static one when off screen), offer a setting to animate only on hover, and respect prefers-reduced-motion by showing static frames.

Embeds arrive later

When a message has a link, the server fetches the page's preview data (title, description, image, its size) after the message is sent. The client first gets the message without embeds, and up to a few seconds later a MESSAGE_UPDATE adds them. A message can carry up to 10 embeds. Three rules keep this from wrecking the list:

  1. Images in embeds and attachments come with width and height, so the client reserves the box before the image loads. No layout shift when the bytes arrive.
  2. When an embed is added to a message above the viewport, keep the visible message where it is (scroll anchoring), or the text the user reads jumps down.
  3. Images go through a media proxy that resizes to the box size the client asks for (Discord has written about resizing 150 million images a day this way), so a 4,000 px photo shown at 400 px costs a 400 px download, in WebP when the client asks for it.

Reactions and super reactions

A message's reactions come as an aggregated list: for each emoji, a total count, whether the current user reacted (me), and, since super reactions, a split of normal and burst counts plus the colors for the burst animation. The client never loads who reacted until someone opens the reactions view, which pages through users.

Toggling a reaction is an optimistic update (see the Slack guide): flip me, change the count by one, send the request, and roll back with a short message if it fails. The server then broadcasts MESSAGE_REACTION_ADD to the channel's subscribers, including the user's own other devices; the device that made the change recognizes its own echo by the event's user_id and does not count it twice. In a 20,000-viewer channel a popular message gets many reaction events a second, so reaction updates are batched into one render per frame, like presence.

Super reactions (a Nitro feature) play a short burst animation. Play it once when the user adds one, and once when it first scrolls into view for others, never in a loop, and never with reduced motion on. Without these limits, a message with 50 super reactions would replay 50 animations every time the list re-renders.

Check your understanding · Exercise 7

A channel's messages are full of animated emoji, link embeds, and code blocks. Which choices keep scrolling smooth and safe? Select all that apply.

Unread and mentions across 60 servers

The server rail is the user's map of where something is waiting. Each server icon can show a white pill on its left edge (something unread) and a red badge with a number (mentions of the user). The app icon's badge sums the mentions.

Getting this right across 60 servers and thousands of channels, most of them passive, with per-server and per-channel settings, is a data problem more than a UI one. The Slack guide covers how a read marker moves when the user reads; this chapter covers what is different here: many servers, mute settings at several levels, and mass mentions.

Unread is a comparison of two ids

Discord ids are snowflakes: 64-bit numbers whose top bits are a timestamp (milliseconds since the first second of 2015). So a later message has a larger id. For each channel the client keeps a read state: the id of the last message the user acknowledged and a mention count.

Each channel object carries its last_message_id, and a channel is unread when that id is greater than the acknowledged one. No message bodies are needed: a passive server's compact update only has to carry each channel's newest message id, plus any new mention of the user, and the pill updates.

ts
export function serverBadge(g: GuildState, s: Settings) {
  let unread = false;
  let mentions = 0;
  for (const ch of g.textChannels) {
    if (!canView(g, ch)) continue;
    const rs = g.readStates[ch.id];
    mentions += rs?.mentionCount ?? 0;
    const muted = s.isMuted(g.id, ch.id, ch.parentId);
    const newer = BigInt(ch.lastMessageId ?? 0) > BigInt(rs?.lastAckedId ?? 0);
    if (newer && !muted) unread = true;
  }
  return { unread: unread && !s.isMuted(g.id), mentions };
}
Unread and badge for one server. Ids are compared as BigInt, never as strings or numbers.

Note what the code does with mute: a muted channel or server shows no pill, but mentions still count. Muting means "do not tell me about chatter", not "hide it when someone needs me". Mention counts come from the server, which knows who was mentioned; the client only adds them up.

How one server's pill and badge are computed. Four channels: general has last message 9310, newer than its acknowledged 9302, so it is unread. Memes has newer messages but is muted, so it does not count. Mods is not viewable, so it is skipped. News has 1 mention of the user, which counts. The server shows a pill and a badge of 1. Notes: the pill means any unmuted, viewable channel is unread; the badge is the sum of mentions, even when muted; the app badge sums every server's mentions.channellast vs ackedcounts?#general9310 > 9302unread#memes9455 > 9401muted#modsno VIEWskipped#news@you: 1mentionserverpill, badge 1pill: any unmuted, viewable channel is unreadbadge: sum of mentions, even when mutedapp badge: sum of every server's mentions
No message bodies are needed: two ids per channel and a mention count. That is why passive servers can keep their pills and badges right from compact updates.

Settings at four levels

Each server has a default chosen by its admins ("All messages" or "Only @mentions"; big public servers almost always pick the second). The user can override it for the server, for a category of channels, and for a single channel. For each, the choices are all messages, only mentions, or nothing, plus mute for a time (15 minutes up to "until I turn it back on"). At the server level there are extra switches: suppress @everyone and @here, and suppress role mentions.

LevelExampleWins over
Channel override#announcements: all messagesEverything below.
Category override"Voice text" category: nothingServer and default.
User's server settingOnly @mentions, suppress @everyoneThe server default.
Server default (set by admins)Only @mentionsNothing: it is the fallback.

The client resolves these with a lookup from the most specific level down, cached per channel like permissions, and recomputed when a setting changes. Settings are user data synced through the server: change them on the phone and the desktop hears a settings update event on the gateway.

@everyone, @here, and role mentions

  • @everyone notifies every member who can see the channel; @here only those online. Both need the MENTION_EVERYONE permission, so the composer's autocomplete offers them only to users who have it, and in this design warns how many people they will reach ("This will notify 48,210 people").
  • A role mention (@Moderators) notifies everyone with the role. Members can mention a role only if the role is marked mentionable or they have MENTION_EVERYONE.
  • The message arrives with mention_everyone and mention_roles set by the server. The client decides if it counts for this user: does the user have that role, and has the user suppressed @everyone or role mentions in this server?
  • The Maxjourney post notes that an @everyone in a huge server is one of the most expensive server operations, since it must find every member who can see the message. Another reason the client should make it hard to do by accident.

Acknowledging and syncing across devices

When the user is looking at a channel, scrolled to the bottom, with the window focused, the client acknowledges the newest message: it sends the channel id and message id to the server, debounced so a fast channel sends at most one ack every few seconds. The server stores it and sends a read state update to the user's other sessions, so the phone clears its badge within a second or two. Keyboard shortcuts let the user mark read on purpose: Escape for the channel, Shift+Escape for the whole server.

Notifications follow the same data. A desktop notification fires when a message counts as a notification under the resolved settings and the user is not already looking at that channel. To avoid buzzing every device at once, Discord's settings include a timeout: mobile push is held back while the desktop app is active, and sent only after the desktop has been idle for a few minutes. The Notifications center guide covers the notification UI itself.

Check your understanding · Exercise 8

A user muted a large server and set "Suppress @everyone and @here". A moderator posts @everyone and, in another channel, someone mentions the user by name. What should the server icon show? Select all that apply.

Moderation in the UI: slowmode, timeouts, AutoMod, audit log

Large communities live or die by moderation, and most moderation reaches the user as UI: a composer that counts down, a message that was blocked, a menu that lets a moderator time someone out. Each is a small state machine on top of the send flow. The send flow itself (optimistic message, outbox, retry, dedupe by a client nonce) is the same as in the Slack guide; here we add the ways the server can say no.

Slowmode

A channel's rate_limit_per_user sets slowmode: each member may send one message every N seconds, from 0 (off) up to 21,600 (6 hours). Since February 2026 only members with the BYPASS_SLOWMODE permission (bit 52, split out of MANAGE_MESSAGES and MANAGE_CHANNELS) are exempt, so the client checks permissions first.

Decision Where does the slowmode countdown come from?

Server only

Let the user send; if it is too soon, the server rejects with a rate limit error and a retry time.

Pros

  • Always correct.

Cons

  • The user types a message, presses Enter, and only then learns they must wait. The optimistic message has to be pulled back.

Client only

Start a timer on each successful send; disable Enter until it ends.

Pros

  • Instant feedback.

Cons

  • Wrong after a reload, on a second device, or when a moderator changes the slowmode mid-countdown.

Client timer, server corrects

After each confirmed send, start a countdown from the channel's current slowmode.

If the server still answers with a rate limit, take its retry time as the truth and restart the countdown from it.

Keep the draft in the composer while waiting.

Pros

  • Instant feedback in the common case, correct in the rare one.
  • No lost text.

Cons

  • Two sources to reconcile; the server always wins.

What we pick: Client timer, server corrects. The composer shows "Slowmode is enabled" with the time left, the Enter key is disabled, and the draft stays put.

Timeouts

A moderator with MODERATE_MEMBERS can time a member out for up to 28 days. The member object gets communication_disabled_until, a timestamp. While it is in the future, the permission function keeps only view and read history (the implicit rule from the Permissions chapter), so the UI updates by itself: the composer becomes a note with the end time, reaction buttons vanish, and voice channels show as locked.

The client sets one timer for the end time to recompute permissions, so the composer comes back the moment the timeout ends without waiting for an event.

AutoMod: the message that was blocked

AutoMod runs rules on the server before a message is stored: keyword lists, spam detection, too many mentions. A rule can block the message, alert moderators in a log channel, or time the author out. When it blocks, the API rejects the send with a specific error code (Discord documents code 200000, "Message was blocked by automatic moderation"). The client must handle this as its own case, not as a generic failure:

  • Remove the optimistic message from the list. Do not offer "Retry": sending the same text again will be blocked again.
  • Show a notice only the author sees, in the message list where the message would have been, with the custom explanation the admins configured (up to 150 characters), if any.
  • Put the text back in the composer so the author can fix it, rather than losing what they typed.
  • If the rule also timed the author out, the member update arrives on the gateway and the composer locks through the permission function.
A sequence diagram for sending, between the Composer, the Store, the API, and the Gateway. The Composer adds a pending row with a nonce to the Store. The Store posts the message to the API. The API answers with error 200000, blocked by AutoMod. The Store removes the row and restores the draft in the Composer with a notice. The user edits and sends again. The Store posts with a new nonce, and the API answers 200 OK. The Gateway echoes the message, and the Store matches it by nonce. The Composer starts a 30 second slowmode timer.ComposerStoreAPIGatewaypending row, noncePOST message200000: AutoModrestore draftedit, send againPOST, new nonce200 OKecho: match nonceslowmode 30 s
The failure path never retries: AutoMod would block the same text again, so the draft goes back to the author. The success path uses the nonce to match the gateway echo to the pending row, then starts the slowmode countdown.

The audit log

Moderators with VIEW_AUDIT_LOG see who did what: bans, role changes, deleted messages, channel edits. Discord keeps entries for 45 days. The API pages by entry id (before and limit up to 100) and filters by user and action type.

Each response includes the users, roles, and channels its entries reference, because many of them may have been deleted or may be members the client never loaded in this huge server. The client normalizes those into the stores (see the Normalization lesson) so each row can show names. Filters live in the URL, so a moderator can share a link to "all bans by Alex this week".

Think first

A moderator deletes 100 spam messages at once. The client gets one bulk delete event with 100 ids. What can go wrong in the message list, and how do you prevent it?

Check your understanding · Exercise 9

A user's message is blocked by AutoMod. Which client behaviors are right? Select all that apply.

Client architecture, data model, and API

With the hard parts decided, the architecture follows. The guiding rule: connections and long-lived things live in services outside React; data lives in stores; components only read stores and call actions. That way navigation never tears down the gateway or the call, and a gateway event updates every view that cares with one dispatch.

The client architecture. Inside the client, web or Electron, three columns. UI components: Member list by ranges, Messages virtualized, Server rail with unread pills, and Voice bar on every route. Stores: Dispatcher taking actions in, Domain stores by id, Derived stores for permissions and badges, and Voice store with the call state. Services: GatewayClient with resume and subscriptions, RestClient with retries, IndexedDB snapshot, and VoiceService with WebRTC and push-to-talk, which is highlighted. Outside: Gateway, REST API, and Voice server. GatewayClient sends actions to the Dispatcher; RestClient writes into Domain stores; IndexedDB fills Domain stores at start; VoiceService feeds the Voice store. The Dispatcher feeds Domain stores, which feed Derived stores. Domain stores feed Messages and the Member list; Derived stores feed the Server rail; the Voice store feeds the Voice bar. GatewayClient, RestClient, and VoiceService each talk to their server.Client (web or Electron)Member listby rangesMessagesvirtualizedServer railunread pillsVoice barevery routeDispatcheractions inDomainstores by idDerivedperms, badgesVoice storecall stateGatewayClientresume, subsRestClientretriesIndexedDBsnapshotVoiceServiceWebRTC, PTTGatewayone socketREST APIhistory, sendsVoice serverper callUI calls actions; actions call the services
Connections live in services that never unmount; components only read stores. A gateway event takes one path, service to dispatcher to stores to the few components that select that data, whatever screen is open.
PartResponsibilityWhy it is a separate piece
Gateway clientSocket, heartbeat, resume, streaming decompression, turning frames into actions.Outlives every screen. One place handles reconnect for all servers.
Subscription managerTurns "what is on screen" (server, channel, member ranges) into batched subscription messages.Views should not talk to the socket. Batching needs one owner.
Dispatcher and storesEach store owns one kind of data (servers, channels, roles, members, member lists, messages, read states, voice states, settings, emoji) and handles the actions it cares about.A single gateway event touches several kinds of data; each store updates its part.
Derived storesPermissions per channel, notification settings per channel, unread and badges per server.Computed from other stores and cached; recomputed only when an input changes.
REST clientHistory pages, sends, reactions, uploads, audit log, settings writes.Request and response work with retries and error codes.
Voice serviceVoice socket, WebRTC peer connection, devices, push-to-talk, speaking levels.A call must survive navigation and gateway reconnects.
Content pipelineMarkdown parse cache, emoji and media URLs at the right size, embed layout.Shared by the message list, replies, search results, and pins.
Local cache (IndexedDB)A snapshot of servers, channels, roles, read states, and recent messages.Paint before the network; survive restarts.

Decision How should client state be organized?

Server-state library per request

Each view fetches what it needs and caches it by query key (TanStack Query style).

Pros

  • Great for request and response data like the audit log or a profile card.

Cons

  • Most data here arrives as pushed events, not responses. A message event would have to patch many query caches by hand.

One big store

One state tree with reducers for every event (Redux style).

Pros

  • One place to look; time-travel debugging.

Cons

  • Every event runs through every reducer; at 50 events a second the overhead adds up.
  • Selectors must be written with care so a presence change does not re-render the message list.

Many small stores and a dispatcher

Gateway events become actions. Each store registers for the actions it cares about and updates itself.

Components subscribe to one store with a narrow selector through useSyncExternalStore.

Pros

  • A presence event touches only the member list and presence stores; nothing else wakes up.
  • Stores can depend on each other in a declared order (permissions wait for roles).

Cons

  • More pieces, and the update order between stores must be explicit.

What we pick: Many small stores with a dispatcher, plus a request cache for pure request and response screens (audit log, profiles). This is the Flux pattern. Discord has not documented its client's internals, but developers of client mods who inspect the web client describe it as a React app built this way: a central dispatcher and dozens of domain stores. Whatever library you name in the interview, the boundary is the point: events in, small stores, narrow subscriptions.

Data model: complete where small, partial where big

The data model splits by size and speed. Small structural data is complete for every server. Big data is partial and keyed by what is on screen. Everything is normalized: one record per id, references by id (see the Normalization lesson).

The client data model. A Guild has Roles, Channels, Members, and Emoji. A Channel has Overwrites, with allow and deny bits, and a Message window. A MemberList, keyed by list id, holds items that point to Members. A VoiceState points to a Member. Notes: a ReadState exists per channel with the last acknowledged id and mentions; Member, Message, MemberList, and presence are partial, loaded only for what is on screen.GuildcompleteRolebits, positionChannellastMessageIdMemberroleIdsEmojiper guildOverwriteallow, denyMessagewindowMemberListby list idVoiceStatemute, deafReadState: per channellastAckedId, mentionspartial: Member, Message,MemberList, presence
Everything is normalized by id. Guilds, roles, channels, and overwrites are complete for every server and small; members, messages, and member lists are partial windows that come and go with the screen.
ts
type Guild = { id: string; name: string; iconHash?: string; ownerId: string;
  roleIds: string[]; channelIds: string[]; defaultNotifications: 0 | 1 };
type Role = { id: string; name: string; position: number; color: number;
  hoist: boolean; mentionable: boolean; permissions: bigint };
type Overwrite = { id: string; type: "role" | "member"; allow: bigint; deny: bigint };
type Channel = { id: string; guildId: string; type: "text" | "voice" | "category";
  parentId?: string; position: number; lastMessageId?: string;
  rateLimitPerUser: number; overwrites: Record<string, Overwrite> };
type Member = { userId: string; guildId: string; nick?: string; roleIds: string[];
  timedOutUntil?: number };
type MemberList = { listId: string; groups: { id: string; count: number }[];
  items: (Item | undefined)[]; subscribed: [number, number][] };
type Message = { id: string; channelId: string; authorId: string; content: string;
  editedAt?: string; embeds: Embed[]; attachments: Attachment[];
  reactions: { emoji: EmojiRef; count: number; burst: number; me: boolean }[];
  nonce?: string; status: "sent" | "pending" | "failed" };
type ReadState = { channelId: string; lastAckedId: string; mentionCount: number };
type VoiceState = { userId: string; channelId: string | null; selfMute: boolean;
  selfDeaf: boolean; mute: boolean; deaf: boolean; selfStream: boolean };
Client entities. Ids are snowflake strings; permission fields are BigInt after parsing.
EntityLives inComplete or partialPersisted locally
Guild, Role, Channel, EmojiStores, from READYComplete for every serverYes
MemberMember storePartial: list rows, message authors, voice usersOnly those in cached messages
MemberListMember list store, by list idPartial: subscribed rangesNo: it goes stale in seconds
MessageMessage store, a window per channelPartial: recent and scrolled-to pagesLast 50 for recent channels
ReadStateRead state storeCompleteYes
VoiceStateVoice state storeComplete per server (small)No
PresencePresence storeOnly for visible members and friendsNo
Current server, channelThe URL: /channels/:guildId/:channelId-Via the URL and history
Composer draftsDraft storePer channelYes, so they survive a restart

Gateway messages

json
{ "op": 10, "d": { "heartbeat_interval": 41250 } }
{ "op": 2,  "d": { "token": "...", "properties": { "os": "Mac OS X" } } }
{ "op": 0,  "s": 1, "t": "READY", "d": { "session_id": "...",
  "resume_gateway_url": "wss://...", "guilds": [], "read_state": [] } }
{ "op": 14, "d": { "guild_id": "8135", "typing": true,
  "channels": { "9244": [[0, 99], [300, 399]] } } }
{ "op": 0,  "s": 812, "t": "GUILD_MEMBER_LIST_UPDATE", "d": { "id": "everyone",
  "guild_id": "8135", "groups": [{ "id": "online", "count": 24301 }],
  "ops": [{ "op": "DELETE", "index": 5 }, { "op": "INSERT", "index": 2, "item": {} }] } }
{ "op": 4,  "d": { "guild_id": "8135", "channel_id": "9301",
  "self_mute": false, "self_deaf": false } }
{ "op": 6,  "d": { "token": "...", "session_id": "...", "seq": 812 } }
Gateway frames: an op code, data, and for dispatches a sequence number and event name.

Opcodes 10, 2, 0, 4, and 6, and the session_id and resume_gateway_url fields of READY, are in Discord's public gateway docs. The read_state field, opcode 14, and GUILD_MEMBER_LIST_UPDATE come from community documentation of the official client. In an interview, present those as "a subscribe message and a list update event" and name them as Discord's only if asked.

REST calls

CallPurposeNotes
GET /channels/:id/messages?before=:mid&limit=50Page history up; after pages down, around jumps to a message.Cursor by message id; ids sort by time.
POST /channels/:id/messagesSend, with a client nonce to match the echo on the gateway.Errors: 429 with code 20016 and a retry_after for slowmode, code 200000 for AutoMod, 50013 for missing permissions.
PUT /channels/:id/messages/:mid/reactions/:emoji/@meAdd a reaction (DELETE to remove).Optimistic on the client.
GET /guilds/:id/audit-logs?before=:eid&limit=50&action_type=22Audit log page, here filtered to bans.Response includes referenced users and channels.
PATCH /guilds/:id/members/:uidModerator action: communication_disabled_until for a timeout.Needs MODERATE_MEMBERS and a higher role.

Errors carry a numeric code and a message, and the client maps codes to UI, not messages, because messages are for developers and may change. A small table in the client turns each known code into a state: countdown, locked composer, "blocked" notice, or a generic retry.

Data flow, end to end

Data flow for the main screen. Reads, top lane: the Gateway sends compressed frames to the GatewayClient, which turns them into actions for the Stores, which components read through selectors. Writes, bottom lane: Components send a message to the Stores, which add an optimistic row and call the RestClient, which posts with a nonce to the REST API. Below, IndexedDB gives the Stores a snapshot at start, and the Media CDN sends images straight to the Components.GatewayeventsGatewayClientinflate, parseStoresnormalizedComponentsvirtualizedREST APIwritesRestClientretriescompressedactionsselectorsnoncePOSTsendIndexedDBsnapshotMedia CDNsized WebPsnapshotimages
Reads and writes take different paths but meet in the stores: a send shows at once as a pending row, and the gateway echo with the same nonce confirms it. Images skip the stores entirely and load at the displayed size from the media CDN.

Follow one message from someone else. The server sends MESSAGE_CREATE on the gateway. The gateway client decompresses the frame, parses it, and dispatches an action.

The message store appends it to the open channel's window; the channel store moves last_message_id; the read state store adds a mention if needed; the member store saves the author's member data that came with it. The derived badge store recomputes the server's pill. React re-renders only the message list and maybe one server icon, once for this frame. In a passive server only the compact update arrives, and only the channel and read state stores change.

Memory in a process that runs for days

The desktop app is Electron: a Chromium renderer running the same web code. People leave it open for days, in voice, with dozens of servers. Memory that grows by 1 MB an hour is 168 MB a week.

Discord has been open about this: in late 2025 it tested having its Windows app restart itself when memory passed 4 GB, but only after the app had run for an hour and the user had been idle for 30 minutes, was not in a call, and at most once a day, restoring the open channel and drafts. A restart as a safety net only works if the app can rebuild its state fast, which is one more reason for the local cache.

  • Bounded message windows: keep full message data for about the 10 most recently visited channels, up to a few hundred messages each, and evict the rest by least recent use. Reopening an evicted channel paints from IndexedDB, then fetches.
  • Member data by reference: member and user records are kept while something on screen or in a cached window refers to them, and swept when member list ranges are dropped.
  • Media: off-screen animated images switch to static frames; image elements are removed by the virtualized list, not hidden; object URLs are revoked.
  • Listeners and timers: every subscription has an unsubscribe tied to component unmount or service teardown. Leaks here are the classic slow growth; see the Memory leaks lesson for heap snapshot diffing.
  • Measure in production: report memory samples (where the platform exposes them) with session age, so a leak shows up as a slope in the dashboards before users notice it.

IndexedDB: a cache, not a source of truth

On start, the client reads a snapshot from IndexedDB (see the IndexedDB lesson): servers, channels, roles, emoji, read states, and the last 50 messages of recent channels. It paints the rail, the channel list, and the last channel from that, then connects. When READY arrives it replaces the snapshot's structural data wholesale, and the open channel fetches messages newer than its newest cached id.

The cache is written in the background, throttled to once every few seconds, in one transaction per write. It is wiped on logout, and its schema has a version number: on a mismatch the client discards it instead of migrating, since everything in it can be fetched again.

Check your understanding · Exercise 10

Which of these pairings of data and home are right? Select all that apply.

Check your understanding · Exercise 11

Which data should the client persist in IndexedDB for a fast start? Select all that apply.

Performance, accessibility, and keyboard

Where the milliseconds go

MomentBudgetHow
Warm start to first paintUnder 1 sPaint from the IndexedDB snapshot before the gateway connects; load only the shell, the rail, the channel list, and the message list code.
Switch to a cached channelUnder 100 msMessage window in memory; parsed trees cached; member list from cache, then re-sync.
Gateway burst of 100 eventsOne frame of workQueue events; apply all in one requestAnimationFrame batch; one render.
Click a reaction, toggle muteINP under 200 msOptimistic store update first; network after; no layout reads in handlers.
Scroll a message list full of media60 frames a secondVirtualized rows, reserved media sizes, static frames off screen.
  • Split code by feature, not just by route. Voice and video (WebRTC setup, audio processing), server settings, the audit log, the Lottie player, and the syntax highlighter are each loaded the first time they are needed. A user who never joins voice never downloads voice code.
  • Variable row heights. Messages range from one line to an image grid with embeds. The virtualized list measures rows after render with a ResizeObserver and keeps estimated heights for rows it has not measured, anchoring on the newest message (see the Virtualized list guide).
  • Parsing off the main thread, carefully. Moving gateway decompression and JSON parsing to a worker frees the main thread, but every event then crosses postMessage, which copies data. It pays off for big payloads like READY; for a stream of small events, batching them per frame matters more than where they are parsed. Measure before moving.
  • Images are requested at the displayed size from the media proxy, with loading="lazy" and decoding="async", and width and height set so nothing shifts.

Keyboard: four regions and a quick switcher

Discord's screen has four regions: the server rail, the channel list, the messages, and the member list. Discord wrote about making its whole desktop app usable by keyboard and found that the hard part was showing where focus is: CSS outlines got clipped by scroll containers and did not match rounded shapes. Its answer was to draw focus rings in a separate layer per scrollable area (components it calls FocusRing and FocusRingScope), detached from the focused element.

  • Between regions: one key moves focus to the next region (this design uses F6, the common desktop convention), and each region remembers its last focused item.
  • Inside a region: one tab stop per list, then arrow keys move between items (roving tabindex, see the Keyboard navigation lesson). In the server rail, Up and Down move between servers and Enter opens one.
  • Shortcuts: Discord documents hotkeys to move to the previous or next channel (Alt+Up, Alt+Down), the next unread channel (Alt+Shift+Down), and the previous or next server (Ctrl+Alt+Up, Ctrl+Alt+Down). They read from the same stores as the UI, so "next unread" respects mute settings.
  • Quick switcher: Ctrl+K (Cmd+K on Mac) opens a search over servers, channels, DMs, and people, with prefixes to narrow it, such as # for text channels and @ for people. It is a command palette over local stores, ranked by recent use; the Command palette guide covers the ranking and the combobox pattern.

Screen readers and motion

  • Each region is a labelled landmark ("Servers", "Channels", "Messages in #general", "Members"). The member list's group headers are real headings with counts, so a screen reader user can jump between "Moderators, 12" and "Online, 24,301".
  • New messages in the open channel are announced through a polite live region, throttled: in a channel with 10 messages a second, announce "12 new messages" every few seconds, not each one. Messages in other channels are never announced; their unread state is in the channel's accessible name ("general, 3 mentions").
  • Voice events (someone joined, someone left, you were muted by a moderator) get both a short sound and a text announcement. The speaking ring is not announced: it would be noise.
  • Discord's accessibility settings include reduced motion (which can follow the operating system's prefers-reduced-motion), a saturation slider, a switch for playing animated emoji, and a choice of when stickers animate: always, on interaction, or never. Every animation in this design reads one motion setting.
  • Role colors are chosen by server admins and can have poor contrast on the theme's background. The renderer can adjust a role color's lightness until it meets a 4.5 to 1 contrast ratio, keeping its hue.

Security in a desktop shell

Content from strangers is rendered in a process that, in Electron, could reach the operating system. So the renderer runs with contextIsolation on, nodeIntegration off, and the sandbox enabled, and talks to native features only through a small, typed preload bridge. Researchers reported a remote code execution chain in the Discord desktop app in 2020 that relied on context isolation being off, and Discord fixed it; it is the standard story for why these flags matter.

On top of that: the parse-tree renderer never builds HTML from message text, embeds are drawn from structured fields, links open in the system browser, and a strict Content Security Policy blocks inline scripts.

Check your understanding · Exercise 12

Which choices make a busy Discord channel usable with a screen reader and keyboard? Select all that apply.

Running it in the interview

"Design Discord" is wide enough to fill two hours. In 45 minutes the risk is spending 30 of them on chat basics the interviewer has heard a hundred times. Get the basics on the board quickly, then spend the time on what is specific: scale per server, the member list, permissions, and voice.

MinutesWhat to doWhat to say out loud
0 to 5Scope questions; write the numbers."Servers up to 500,000 members and tens of thousands online; voice next to text; web and desktop; I will reuse a standard chat core."
5 to 10Draw the wireframe: rail, channel list, messages, member list, voice bar."Each region has a different data problem. Let me name them."
10 to 20Gateway and subscriptions, then the member list with ranges."Traffic must follow the screen, not the server size."
20 to 28Permissions: the algorithm, bits, and hide versus disable."The client computes to shape the UI; the server enforces."
28 to 36Voice: two connections, the state machine, the persistent voice bar."The call lives in a service outside the route tree."
36 to 45Architecture and stores, then pick one or two of: rich content, unread across servers, memory, accessibility."Here is what I would measure in production."

When the interviewer pushes on one area, go deep there and drop something else; say which part you are dropping and why. A clear "I will skip rich content and spend the time on voice failures" reads as judgment, while rushing through everything reads as a checklist.

Follow-up questions to expect

Think first

"The member list header says Online, 24,301. How does the client keep that number right without getting every presence change?"

Think first

"An admin removes the role that lets Maya see #staff while she is reading it. What happens on her screen?"

Think first

"The user's Wi-Fi drops for 8 seconds during a voice call. Walk me through the UI."

Think first

"Someone sends @everyone in a 1,000,000-member server. What does the client do, for the sender and for everyone else?"

Check your understanding · Exercise 13

You have 10 minutes left and have covered the gateway, member list, and permissions. Which are strong uses of the time? Select all that apply.

Summary

  • Discord is team chat plus lopsided scale: one user's servers range from 4 people to 1,000,000. Every per-server structure needs a plan for the huge case.
  • Traffic must follow the screen. One gateway connection carries all servers; passive servers send compact unread updates, and the client subscribes to the server, channel, and member ranges it shows.
  • The member list is server-sorted and flattened into one list of headers and rows. The client subscribes to 100-row ranges, applies insert, update, and delete operations in order, and virtualizes the rest with placeholders.
  • Presence is throttled on the server and batched per frame on the client. Status dots update in place; rows moving between groups are applied together to avoid jumpy lists.
  • Permissions are computed on the client with the documented algorithm (base roles, administrator, @everyone overwrite, role overwrites with allow winning, member overwrite, implicit rules) using BigInt, to hide or disable UI. The server always enforces.
  • Voice uses a second connection: the gateway says which voice server to use, and media runs over WebRTC, end-to-end encrypted with DAVE. The call lives in a service outside the route tree, shown by a persistent voice bar, and is modeled as a state machine.
  • Messages are parsed into a tree and rendered as elements, never HTML strings. Custom emoji animate only on screen and never under reduced motion; embeds arrive later with reserved sizes and scroll anchoring.
  • Unread is a comparison of snowflake ids; mentions count even in muted servers; notification settings resolve from channel to category to server to the admins' default.
  • A desktop client open for days needs bounded caches, by-reference member data, and a fast rebuild from IndexedDB, because memory growth is a real, public problem for Discord.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next