R18 — Stateful real-time servers: WhatsApp, Google Docs, stock broking, Zoom

R0 to R17 built the general tools and used them on infrastructure. R18 to R22 apply them to the product questions interviewers reach for most often. Instead of grouping those products by industry, these five sections group them by the engineering problem they share, because that is what you are actually graded on:

Section The shared problem Systems
R18 State that has to live in one server's memory WhatsApp, Google Docs, stock broking, Zoom
R19 Many people competing for the same scarce thing BookMyShow, Amazon, Uber
R20 Very large objects: storing, syncing and delivering bytes Dropbox, Netflix
R21 Huge numbers of small events: one-to-many and many-to-one Instagram, live scores, notifications, Datadog
R22 Finding things fast with precomputed indexes URL shortener, typeahead, proximity search, web crawler

Each section opens with the shared problem and the toolkit for it. Then it walks through its systems in an order where each one adds a new difficulty on top of the last. Each walkthrough is a transcript condensed from a 45–60 minute mock: clarify, requirements, capacity, API, data model, high-level design, deep dives, failures, curveballs. The teaching notes sit after the exchange they belong to. Read the dialogue once for flow, then reread only the notes.


The shared concern — state that cannot leave the server

R1 gives the default rule: keep servers stateless. Put the data in a database, so any server can handle any request and a dead server loses nothing. That rule is right for most services. This section is about the three situations where it runs out:

  1. The state is a live connection. A phone holding a WebSocket is connected to one specific machine. You cannot put a socket in a database. Whoever wants to reach that phone has to find that machine.
  2. Many people change one object at once, and every change must be ordered. Ten people typing into one document need a single place that decides "this edit came first". Sending every keystroke through a database lock is too slow, so the live document sits in one server's memory.
  3. The answer is needed in microseconds, and depends on the previous answer. A broker checking "does this customer have enough money for this order?" cannot wait for a database round trip on every order, and two orders checked at the same moment must not both spend the same rupee.

In all three, some state must live in one server's memory. That creates four problems. Every stateful design has to answer them, and each one follows from the one before.

Problem 1 — finding the server that holds the state

If the state lives on one machine, every request for it must reach that machine. There are two ways to route:

Problem 2 — making sure only one server holds it

Routing is only safe if there is exactly one owner. If two servers both think they own a document, each accepts edits in its own order, and the copies drift apart for good. This is split brain. The fix is R8's: ownership is a lease that expires, and every new owner gets a bigger epoch number, a fencing token. Every write carries the epoch, and anything stamped with an older one is refused. A server that froze, lost its lease and woke up can then do no harm.

Problem 3 — not losing the state when the server dies

Memory disappears in a crash, so the durable truth must live somewhere else. There are three standard moves, each stronger than the last:

Problem 4 — recovering without a stampede

When a server with 500,000 connections dies, 500,000 clients reconnect at the same moment. They must not all hit the next server in the same second. So clients reconnect with randomised delays (jitter), and resume from a token — the last message or revision they saw — instead of downloading everything again.

Rules of thumb for stateful servers

How the four walkthroughs build on each other

WhatsApp Google Docs Stock broking Zoom
What lives in memory Sockets and who is online The live document being edited Live prices per connection; each account's available money; exchange sessions Each meeting's media session: who sends which streams to whom
Why it cannot live in a database A socket is tied to one machine Every keystroke needs one ordering point, fast Each order's risk check needs microseconds and must see the previous order's effect Media packets must be forwarded within milliseconds, by the server holding the meeting
The unit with one owner The connection The document The account (for risk) and the instrument (for prices) The meeting, on one media server (SFU)
Routing Registry device → gateway, with an epoch Lease docId → server, with an epoch Hash of account to a risk shard; subscriptions per gateway Signalling picks the SFU; a lease records the owner
How it survives a crash Nothing to lose: messages live in a durable inbox Op log written before acknowledging; snapshot plus replay Replicated input journal; deterministic replay on a hot standby Nothing durable to replay: clients reconnect to another SFU and media resumes
Latency target Under a second end to end Tens of milliseconds per edit Microseconds per risk check; a quarter-second for prices Under ~150 ms mouth to ear; late packets are dropped, never retried

They are ordered so that each adds one difficulty:

  1. WhatsApp — the simplest kind of state, a connection. The server holds sockets, but the truth stays in a durable inbox, so losing a gateway loses nothing that matters. You learn problems 1, 2 and 4.
  2. Google Docs — now the state is the object itself. One server must order every edit to a document, so problem 3 becomes real: persist each edit before acknowledging, and recover from snapshot plus log.
  3. Stock broking — everything at once, with money and microseconds at stake. Millions of connections receive prices, as in WhatsApp but lossy by design. Each account's risk state has one owner, as each Google Doc does. And recovery must be instant, which brings in deterministic replay on a hot standby — and, if the interviewer means the exchange, the matching engine.
  4. Zoom — the state is a live media session, and for the first time late data is worthless: media is dropped, never retried. One media server owns each meeting, connections are UDP rather than a reliable socket, and recovery means reconnecting quickly rather than replaying a log.

At a glance

Walkthrough What it really tests The line to say
WhatsApp Holding 500M sockets and never losing a message once you said "sent" "Persist before acknowledge; the inbox, not the registry, is the source of truth."
Google Docs Convergence under concurrent edits; OT versus CRDT; exactly one ordering point per document; durable acks "One fenced owner per document orders every op; OT rewrites positions against what the client had not seen."
Stock broking Two workloads — lossy prices and exact orders; single-writer risk with deterministic replay; stateful exchange sessions "Drop old prices, never connections — and replicate the input log, not the state."
Zoom Signalling vs media; WebRTC and NAT traversal; mesh vs MCU vs SFU; simulcast; loss and jitter; one SFU per meeting "Forward, don't mix: an SFU per meeting, simulcast layers per receiver, and drop late packets rather than wait for them."

Walkthrough 1 — WhatsApp, real-time messaging with end-to-end encryption

Where this starts: the simplest kind of stateful server. WhatsApp's gateways hold half a billion live connections, but no data that matters — every message lives in a durable inbox. So this walkthrough is about routing to the right machine, guarding against two owners, and reconnecting millions of clients without a stampede.

Scope and requirements

Interviewer: Design WhatsApp.

You: Questions first, because each answer changes the design. 1:1 only, or groups — and how large? Which message types — text, media, calls? Is end-to-end encryption required? Does the server keep history or only relay? Multi-device? Receipts and presence? What ordering guarantee? And scale.

Interviewer: You decide.

You: 1:1 and groups up to 1,024 members. Text and media up to 100 MB; calls out of scope. E2EE required, so the server only ever routes ciphertext. One phone plus up to four linked devices. Delivery and read receipts, presence and last seen, with privacy settings. Ordered per conversation, not globally. 1B DAU sending 40 messages a day. And store-and-forward: the server holds a message only until every recipient device has it, then deletes it; undelivered messages expire after 30 days.

Interviewer: Users like old chats. Why not keep everything on the server?

You: Cost, privacy and simplicity. Five years of history is ~22 PB before replication against a few TB for a relay inbox — I'll show the math. With E2EE the server could only store ciphertext it can never search, so most of the value of server history is gone anyway. And a queue that drains is easier to run than an archive that grows forever. History lives on the device in SQLite, with optional client-encrypted backups to iCloud or Google Drive. If the product needed Slack-style history on any device without the phone, I would switch to a server-side store and pay for it.

What's being probed: whether you shape the problem before drawing boxes, and defend a scoping decision with numbers.

Concept — Store-and-forward versus system of record. A store-and-forward server is a post office: it holds a letter until it is collected, then discards its copy. A system of record is a library that keeps everything. The first needs a small, fast, constantly churning store; the second a huge, mostly cold archive with indexes. Choosing early sets the storage engine, the cost model and the privacy story.

Takeaway: Relay-only keeps the server small and private; history is the client's job.

Interviewer: Requirements?

You: Functional: send and receive 1:1 and group messages in real time; media; one tick, two ticks, blue ticks; offline delivery on reconnect, in order, with no gaps; push to wake offline apps; presence and typing; every linked device receives every message; E2EE for messages and media. Non-functional: p99 under 300 ms online-to-online in a region; 99.99% on the send path; once the server acks, the message is never lost; at-least-once on the wire, exactly-once on screen; per-conversation order; ~500M concurrent connections; minimal metadata. Out of scope: calls (a separate WebRTC plane), status, payments, channels, spam internals, contact discovery.

Two requirements drive everything: "no loss after ack" shapes the write path, and "500M open connections" shapes the gateway tier. And delivery is at-least-once on every hop with dedup by message id, so the screen shows exactly-once without a distributed transaction — the same move R3 makes at an API boundary.

Capacity

Interviewer: Size it.

You: Assumptions: 500M peak connected devices, a blended fan-out of 2 recipients per message, 300 bytes per encrypted text on the wire, 5% of messages are media at ~200 KB, 2 receipts per delivery, 5% undelivered at any moment, 500K connections per gateway host.

Quantity Average Peak (3x)
Sends (40B/day) ~460K/s ~1.4M/s
Deliveries (x2) ~925K/s ~2.8M/s
Receipts (x2 per delivery) ~1.85M/s ~5.5M/s
Total gateway frames — ~10M/s

Now turn those rows into machines, bandwidth, disk and memory, one step at a time.

1. Receipts are the biggest load — so make them cumulative. Every delivered message produces two receipts: delivered (✓✓) and read (blue ticks).

receipts at peak = deliveries × 2 = 2.78M/s × 2 ≈ 5.56M/s
messages at peak = sends + deliveries = 1.39M/s + 2.78M/s ≈ 4.17M/s
total frames     = 1.39M + 2.78M + 5.56M ≈ 9.7M/s  → ~10M/s

Receipts, not messages, are the largest frame type, so receipts must be cumulative — "read up to seq N". Open a chat with 30 unread messages and the phone sends one receipt, not 30. If one receipt covers 5 messages on average:

receipts at peak = 5.56M/s ÷ 5 ≈ 1.1M/s
total frames     = 1.39M + 2.78M + 1.1M ≈ 5.3M/s   — roughly half the load gone

2. Gateway hosts. One host holds 500K open connections. Add 50% headroom, because when a host dies or is redeployed its users reconnect to the others, and those must have room.

hosts = 500M connections ÷ 500K per host = 1,000
      × 1.5 headroom                     ≈ 1,500 hosts

3. Memory per host. Each connection costs about 20 KB for buffers and session details.

500K connections × 20 KB = 10,000,000 KB ≈ 10 GB of RAM per host — affordable

4. Text bandwidth is small. Count every frame at the full 300 bytes, which overestimates, since receipts are far smaller:

10M frames/s × 300 B = 3 GB/s × 8 bits ≈ 24 Gbps across the fleet
24 Gbps ÷ 1,500 hosts ≈ 16 Mbps per host

Text is not the bandwidth problem. Media is (step 7).

5. The inbox: huge writes, tiny footprint. The server is a relay, not an archive. A message waits in each recipient device's inbox only until that device receives it, then the row is deleted.

rows written per day = 40B sends × 2 recipients = 80B
bytes written        = 80B × 300 B             = 24 TB a day

How much sits on disk at any moment? Little's Law: items held = arrival rate × time each one stays. Split the rows by how long they stay:

95% delivered within ~2 s (recipient online):
  76B/day ÷ 86,400 s ≈ 880K rows/s × 2 s ≈ 1.8M rows × 300 B ≈ 0.5 GB   — negligible
 5% wait ~1 day (phone off):
  4B rows/day × 1 day = 4B rows × 300 B ≈ 1.2 TB
working set ≈ 1.2 TB; × 3 replicas ≈ 3.6 TB → ~4 TB

6. What an archive would cost instead. If the server kept every message for five years, stored once per message rather than once per recipient:

40B × 300 B = 12 TB a day
× 365       ≈ 4.4 PB a year
× 5 years   ≈ 22 PB, before replication

About 22 PB against about 4 TB. Keeping history on the phone and only relaying on the server is what makes storage cheap.

7. Media is the real bytes problem. 5% of messages carry a photo or video of about 200 KB, kept 30 days so recipients have time to download it.

objects per day  = 40B × 5%            = 2B
bytes per day    = 2B × 200 KB         = 400 TB a day   (≈ 33× all the text)
stored           = 400 TB × 30 days    ≈ 12 PB
upload rate      = 400 TB ÷ 86,400 s   ≈ 4.6 GB/s ≈ 37 Gbps average, ~111 Gbps at peak
download rate    = upload × 2 recipients ≈ 220 Gbps at peak — ~9× the text traffic

So media must bypass the chat servers: the phone uploads straight to blob storage, and the chat message carries only a link and a decryption key — R20's "facts separate from bytes".

8. Caches that must sit in memory.

session registry   = 500M devices × 100 B             = 50 GB; × 3 replicas ≈ 150 GB
presence           = 500M users × ~32 B                ≈ 16 GB
group membership   = ~100M active groups × 10 members × ~32 B ≈ 32 GB

All three fit comfortably in a Redis cluster, which keeps routing and fan-out off the database.

What the numbers say: the traffic is enormous but almost none of it stays, and text needs little bandwidth. The two real costs are receipts — fixed by making them cumulative — and media — fixed by keeping it off the chat servers.

What's being probed: whether you separate throughput from footprint.

Concept — Working set versus ingest. Ingest is what you write per day; working set is what you hold at any moment. Little's Law ties them: items held = arrival rate x time each stays. 24 TB a day arrives but only the undelivered tail stays, so about 1.2 TB sits on disk — a busy airport gate that is always nearly empty. That is why a relay inbox is cheap and an archive is not.

Takeaway: Footprint = rate x dwell time; short dwell makes huge traffic cheap to store.

API

Interviewer: What does the client-server interface look like?

You: One long-lived connection — a compact binary protocol over TLS on mobile, WebSocket on web — carrying frames, not REST calls. Media and keys use HTTPS. R1.4 compares the transports; at this battery and latency budget only a persistent duplex connection fits.

Type Name Purpose
WSS /v1/connect Authenticate device, open the connection
C→S SEND Encrypted message to a user or group
S→C ACK Server persisted it: one tick
S→C DELIVER Push a message to a recipient device
C→S RECEIPT Delivered / read up to a sequence number
C→S SYNC After reconnect, everything after my last acked seq
HTTPS POST /v1/media/upload-url Pre-signed URL for encrypted media
HTTPS GET /v1/keys/{user}/bundle, PUT /v1/keys/prekeys E2EE session setup
{ "type": "SEND", "client_msg_id": "3f9c2a7e-1b4d-4f0a-9c2e-7d1e5a8b6c01",
  "to": { "kind": "user", "id": "u_918877" },
  "envelopes": [
    { "device_id": "u_918877:0", "ciphertext": "base64...", "type": "signal_msg" },
    { "device_id": "u_918877:2", "ciphertext": "base64...", "type": "prekey_msg" },
    { "device_id": "u_123456:1", "ciphertext": "base64...", "type": "signal_msg" } ] }

{ "type": "ACK", "client_msg_id": "3f9c2a7e-...", "conv_id": "c_123456_918877",
  "seq": 48213, "server_ts": 1790000000180, "status": "SENT" }

The sender encrypts once per recipient device and once per its own other devices. client_msg_id is the idempotency key: the server dedups on (sender_device, client_msg_id) for about 7 days and a retry after a dropped connection gets the original seq back (R3). Auth happens once at connect with a device credential. SYNC pages 500 messages per conversation with a next_seq cursor. Under overload the server sends RETRY_AFTER and the client keeps unacked messages in its outbox.

Data model

Interviewer: What do you store, where?

You:

Entity Store Key Why
Inbox (pending per device) Cassandra / ScyllaDB partition recipient_device_id, cluster (conv_id, seq), TTL 30 d Huge append rate, range read after a cursor, TTL
Conversation seq counter Owner shard in memory, persisted with the inbox write conv_id Monotonic per conversation, single owner
Dedup index Redis with TTL; inbox row is truth (sender_device, client_msg_id) Fast retry check
Session registry Redis Cluster device_id → gateway, epoch Read on every delivery, sub-ms
Users and devices Sharded SQL user_id Device list and identity keys need strong consistency
Prekeys Cassandra device_id, cluster prekey_id Atomic pop-one-key per session setup
Groups Sharded SQL + cache group_id with a version Consistent membership; cached for fan-out

Interviewer: You're deleting Cassandra rows seconds after writing them. Isn't that an anti-pattern?

You: Yes, and I should own it. Deletes are tombstones, and reads slow down stepping over them until compaction. Time-window compaction, a short gc_grace_seconds, reading from the cursor so tombstoned ranges below it are skipped — or better, stop deleting: keep a per-device "acked up to seq" cursor and let TTL expire rows. If it still hurts, ScyllaDB or a log store with segment-level deletion; the design doesn't change, only the engine.

What's being probed: whether you design a partition key and know the store's sharp edges.

Concept — Partition by reader, expire in bulk. The partition key decides which machine holds the rows; the clustering key decides their order inside it. "Everything pending for Bob's phone after seq 41" is one sequential read on one node because the partition is the device and the order is (conv_id, seq). In an LSM store a delete is a write of a marker, so a queue-shaped table should expire by TTL and a cursor rather than row-by-row deletes. R10 covers LSM trees.

Takeaway: Partition by who reads it, cluster by the order they read it, and let TTL do the deleting.

High-level design

Interviewer: Draw it, and walk me through Alice sending "hi" to Bob, who is online.

You: Play the 1:1 flow and the offline flow, and watch one thing: where the ack to the sender sits relative to the inbox write. It always comes after.

An L4 load balancer spreads TCP connections without terminating WebSockets. Chat gateways hold sockets, terminate TLS, register device → gateway and forward frames; they keep no durable state. WhatsApp's publicly described Erlang heritage fits this tier — one lightweight process per connection; in Java it is Netty or virtual threads. The message service, sharded by conv_id, dedups, sequences, persists, routes and handles receipts. The inbox is the durable store-and-forward queue. The group service expands groups to devices. Kafka carries push events and large-group fan-out (R9). The push notifier calls APNs/FCM. The media service issues pre-signed URLs over a blob store and CDN.

  1. Alice's app mints client_msg_id, encrypts per device, writes to its local outbox, sends SEND to gateway A.
  2. Gateway A forwards to the message-service shard owning conv_id(Alice, Bob).
  3. Dedup check; new, so seq = last_seq + 1.
  4. One inbox row per recipient device, quorum write. Only then ACK(seq) — one tick.
  5. Registry lookup: Bob's phone is on gateway B.
  6. DELIVER via gateway B.
  7. Bob's app dedups by msg_id, decrypts, stores, replies RECEIPT(delivered, seq).
  8. The inbox row is cleared or the cursor advances; the receipt reaches Alice — two ticks.

Step 4 writes the inbox even though Bob is online. Online delivery is an optimisation on top of durable storage, not a separate path.

What's being probed: whether you trace one request end to end and put the durability point in the right place.

Concept — Persist before acknowledge. An ack is a promise. If the server says "sent" before the message is on several machines, a crash in between loses a message the sender believes was delivered. So the order is always: store at quorum, then ack, then deliver — and delivery can fail and retry because the stored copy is still there. R4 makes the same argument about publishing.

Takeaway: Never say "got it" until it is written down in more than one place.

Deep dive — gateways and the session registry

Interviewer: How do you hold 500K sockets per host?

You: Event-driven I/O — Netty or virtual threads in Java, an Erlang process per connection in WhatsApp's case — so an idle socket costs kilobytes, not a thread stack (R1.1 shows why, using Tomcat's poller). Heartbeats every 30–60 s, tuned to mobile NAT timeouts; missed heartbeats mark the device offline. No durable state — a dead gateway's clients reconnect elsewhere and sync. Deploys drain gradually: stop accepting, send RECONNECT with jitter, drain over minutes. Per-connection rate and frame-size limits. WhatsApp publicly reported about 2 million connections per server in 2012; I'd treat that as context, not a capacity number.

Interviewer: Bob switches from Wi-Fi to 4G and reconnects to gateway C while gateway B still thinks it holds him.

You: The registry entry carries an epoch from a per-device counter.

on_connect(device):
    epoch = INCR epoch:{device}
    SET sess:{device} = {gw: me, epoch} EX 120
on_heartbeat(device, epoch):
    if GET sess:{device}.epoch == epoch: EXPIRE sess:{device} 120
on_disconnect(device, epoch):
    EVAL "if epoch matches then DEL" sess:{device}

Gateway C registers epoch 18. When B finally notices its dead socket and tries to delete with epoch 17, nothing happens. A delivery in flight to B gets no ack; Bob's sync on C reads it from the inbox; if both copies arrive the client dedups. Stale routing is safe because the inbox, not the registry, is the source of truth. Consistent hashing of users to gateways is the alternative; I reject it because L4 balancing and mobile reconnects make landing on a specific host hard, and losing a host moves many users.

What's being probed: whether you handle a race between an old and a new owner.

Concept — Epochs as fencing tokens. When ownership moves, give each new owner a strictly larger number and make every write or delete carry it; the store rejects anything older. That stops a slow or paused old owner undoing the new owner's work. R8 covers fencing tokens in locks; this is the same idea applied to a routing entry.

Takeaway: Stamp every ownership change with a bigger number and reject the old ones.

Deep dive — guarantees, ticks and ordering

Interviewer: Define each tick, and prove no message is lost.

You:

State Trigger UI
Pending In the local outbox, not acked Clock
Sent Persisted to the inbox, ACK(seq) returned One grey tick
Delivered Every recipient device acked Two grey ticks
Read Recipient opened the chat Two blue ticks

The proof is a chain of custody. The sender holds the message in its outbox until the ack. The server acks only after a quorum inbox write. The inbox row stays until the recipient device acks. Every hop retries and ids make retries idempotent. There is never a moment when the only copy is in flight. Read receipts are one frame — RECEIPT(read, conv_id, up_to_seq = 48213) — which turns every earlier tick blue, goes into Alice's inbox if she is offline, and also reaches Bob's other devices to clear their badges.

Interviewer: Can't you just sort by timestamp?

You: No — retries, multiple gateways and parallel workers reorder, and client clocks skew.

Option Quality Cost
Client timestamp Wrong under skew Free
Server timestamp Ties and cross-server skew Cheap
Global sequence Total order Bottleneck, single point of failure
Per-conversation sequence, one owner Total order within a chat Routing to an owner
Hybrid logical clocks Causal order Complex; no gap detection

Per-conversation sequence. Every message for a conv_id routes to one owner shard by consistent hashing (R10), with leases so only one node owns it (R8). The owner does seq = ++last_seq[conv] and persists it with the inbox write. The recipient seeing 41 then 43 knows 42 is missing and syncs for it. The client sends one at a time per conversation, so the server sees the typed order. A new owner reads max(seq) before accepting writes. When Alice and Bob type at once, server order is canonical and clients reorder once acks land.

What's being probed: whether you know why clocks fail and scope ordering to what the product needs.

Concept — Single-writer sequencing. The simplest total order is one actor handing out numbers, like a deli ticket machine. A global machine would bottleneck, but chats are independent, so each conversation gets its own machine on whichever node owns it. Consecutive numbers also reveal gaps, which is what makes catch-up exact.

Takeaway: One numberer per conversation gives order and gap detection without a global bottleneck.

Deep dive — offline, groups, encryption, presence

Interviewer: Bob's phone is off for six hours.

You: The message is durable in Bob's inbox and Alice sees one tick. No live registry entry, so the message service publishes recipient_offline; the notifier sends a push with no plaintext — "new messages" or an encrypted blob the notification extension decrypts — collapsed per chat and rate limited. Bob's app reconnects, registers epoch 17, sends SYNC with its last acked seq per conversation, and the service streams his partition in (conv_id, seq) order. Bob acks each page; rows clear; delivered receipts flow to senders. Push rings the doorbell; sync with a cursor delivers the mail. A device offline past 30 days misses messages. A newly linked device gets history from the phone, device to device, never from the server.

Interviewer: Groups — 50 members, then 1,024 with most offline.

You: Alice encrypts once with her Signal sender key and sends one frame to g_42. The service expands membership from cache (version 12) to ~120 devices, assigns a group seq, batch-writes inbox rows, acks, then batch-MGETs the registry and delivers or pushes. Receipts aggregate on Alice's client: "read by 30".

Fan-out on write — a row per member device, rather than one shared group log that each member reads with a cursor — because groups are capped: worst case ~2,000 rows per message. Above ~100 members the message goes once to Kafka and workers do the per-member writes in parallel; the sender's ack follows the group-log write, not the fan-out. When a member leaves, everyone rotates sender keys.

Interviewer: Product wants broadcast channels with 5 million followers.

You: Then 2,000 rows becomes 10 million and write fan-out breaks. Channels above a threshold store each post once in a log partitioned by channel_id; each follower device keeps a cursor; push becomes a throttled topic notification; per-follower ticks become aggregate counts. Encryption changes too — sender keys with rotation on every leave do not scale to millions, so channels are either not E2EE the same way or use a scheme like MLS. That is a product and privacy decision, and I'd say so.

What's being probed: whether a size cap changes your answer, and whether you see the knock-on effects when a requirement breaks it.

Concept — Bounded versus unbounded audiences. Copying into every mailbox makes sending expensive and reading trivial; a shared log makes sending cheap and per-reader state harder. A hard cap like 1,024 makes copy-on-send safe. Remove the cap and you switch to a log — and that switch drags receipts and encryption with it, which is what shows senior judgement. Instagram's celebrity problem, in R21, is the same shape.

Takeaway: Bounded groups favour copy-on-send; unbounded audiences favour a shared log.

Interviewer: What does E2EE cost you as the designer?

You: WhatsApp publicly described using the Signal protocol. Each device has an identity key pair; only public keys reach the server. X3DH lets Alice derive a shared secret from Bob's prekey bundle while he is offline; the Double Ratchet gives every message a fresh key, so a leaked key unlocks one message. Media is encrypted client-side with a random AES key, uploaded as ciphertext, and {media_url, aes_key, sha256} travels inside the encrypted message. The server routes sealed envelopes; any feature that needs to read them moves to the client — so no server search, no server moderation, no server link previews. It still sees metadata: who, to whom, when, how big.

Presence is soft state, kept in Redis: set on connect, last_seen on disconnect, published only to current watchers — the people with Bob's chat open — never broadcast to all contacts. Reconnects within 10 s are debounced; typing frames are never persisted; privacy settings are enforced server-side; presence never sits on the delivery path.

Failures and scale

Interviewer: What breaks first?

You:

Failure Mitigation
Gateway crash drops ~500K connections Backoff with jitter to other hosts; registry TTL expiry; inbox prevents loss
Thundering herd after outage or deploy Jittered reconnect, RETRY_AFTER, admission control, staged drains
Registry node failure Replica failover; a stale entry only falls back to push + sync
Hot large group on one owner Owner assigns seq and writes the group log; Kafka workers fan out
Owner crash mid-send No ack, so the sender retries; dedup + quorum write make it idempotent; new owner reloads max seq
APNs/FCM throttling Buffer in Kafka, backoff, collapse keys; messages wait in the inbox

The failure caused by recovery

The one I worry about most is the thundering herd. A gateway crash sends 500K phones back at once; a regional failover sends tens of millions. Without jitter and admission control, the reconnect storm takes down the next gateway. R14 covers backoff with jitter and load shedding.

Interviewer: A whole region goes down.

You: Users are homed near them; their inbox and session entries live there. A cross-region message is forwarded to the recipient's home region over the backbone, and the inbox write happens there. Inboxes replicate asynchronously to a second region with LOCAL_QUORUM writes (R13). On failure, DNS steers clients to the healthy region, which promotes the replica. Replication lag can re-deliver (dedup handles it) or rarely lose the last unreplicated seconds — and the sender's outbox still holds anything unacked and resends. Synchronous cross-region writes would close the gap but add tens to hundreds of milliseconds to every send and break the 300 ms target. Each region runs N+1 to absorb a neighbour.

Curveballs

Interviewer: Why not use Kafka as the inbox?

You: Kafka is shaped for a few large streams that many consumers read in order. An inbox is 500M tiny queues, one per device, each read and expired on its own; you'd multiplex devices into partitions and a reconnecting phone would scan past everyone else's messages. Few big streams: use a log. Billions of tiny queues: use a keyed store. Kafka stays where it fits — push events and large-group fan-out.

Interviewer: Legal wants server-side search, and a regulator asks what metadata you keep.

You: Server search is not possible with E2EE; search runs on the device over its local store, and I'd push back on the request on those grounds. On metadata: we still see sender, recipient, timestamps, sizes, IPs and group membership. We minimise it by deleting after delivery, short log retention, content-free analytics, and features like sealed sender that hide the sender from the server. Encryption hides what is said, not that it was said.

Quick reference

Decision Choice Why
Transport Persistent binary protocol over TLS; WebSocket on web Lowest latency and battery, full duplex
Server storage Store-and-forward, delete after delivery, 30-day TTL Small working set, private, cheap
Inbox Cassandra/ScyllaDB, partition per device Huge write rate, cursor reads, TTL
Durability point Quorum inbox write before ACK No loss after "sent"
Delivery At-least-once on every hop, dedup by id Exactly-once on screen without transactions
Ordering Per-conversation seq from a leased owner Order plus gap detection, scales by conversation
Routing Redis registry with epochs Fast, tolerant of stale entries
Groups Write fan-out (async above ~100), sender keys Bounded at 1,024; encrypt once
Presence Lossy, in memory, published to watchers only No O(contacts) storms
Multi-region Home region per user, async inbox replication Fast writes; client outbox covers the RPO gap

The 60-second version

Every device keeps one persistent TLS connection to a stateless gateway; a Redis registry maps device to gateway with an epoch so reconnect races are harmless. A conversation-sharded message service dedups by client message id, assigns a per-conversation sequence, and writes one inbox row per recipient device to Cassandra at quorum before acking the sender. Online recipients get the message through their gateway and return a receipt; read receipts are cumulative. Offline recipients get a contentless push and sync everything after their cursor on reconnect. Every hop is at-least-once with idempotent ids, so the screen shows exactly-once, and the inbox — not the registry — is the source of truth. Groups fan out on write with sender keys; media is client-encrypted in a blob store behind a CDN; presence is lossy and watcher-only; regions home users and lean on the client outbox to cover async replication.


Walkthrough 2 — Google Docs, real-time collaborative editing

Building on WhatsApp: WhatsApp's servers held sockets but no data that mattered. Here the server holds the document itself, because every keystroke needs one ordering point. So each document gets exactly one fenced owner, and every edit must be durable before it is acknowledged — snapshot plus log is how the state survives a crash.

Scope

Interviewer: Design Google Docs.

You: The hard part is not the number of users, it is concurrency on one object, so my questions go there first. How many people edit one document at once, and how many watch? — that decides whether one server per document can work. Offline, and for how long? — how far two copies may drift. Latency for my keystrokes and others'? — local echo instant, remote within a few hundred ms. Then the rest: rich text only or sheets too, version history, comments and suggestions (anchors on moving text), sharing model, and data residency (where the authoritative copy lives).

Interviewer: Rich text, global, the usual features. You pick numbers.

You: 100M daily actives. Most documents have one editor at a time; a few have 50+ editors and thousands of viewers. A hard cap of about 100 simultaneous editors per document, similar to the publicly documented Google Docs limit; beyond it, users join view-only. Own keystrokes instant; remote edits under 500 ms p99 in-region. Offline for hours to days. Full history, comments, suggestions, Drive-style sharing. Each document has one home region at a time.

Functionally: create, open, edit, share; many editors at once with everyone converging; live cursors and presence; offline with merge on reconnect; version history and restore; anchored comments and suggestion mode; role-based and link sharing. Out of scope: formulas, slide layout, spelling and AI, export pipelines, billing.

The correctness property I'd name precisely is convergence plus intention preservation. Durability is a separate promise: an acknowledged edit is never lost. Availability 99.95% for editing, with reconnect in seconds if a server dies. Revoked access takes effect within seconds.

What's being probed: whether you find the hard axis, and name the correctness property precisely.

Concept — convergence versus intention preservation. Convergence: any two replicas that have applied the same set of edits show the same text, whatever order the edits arrived in. It is easy to get cheaply and wrongly — last writer wins on the whole document converges by throwing work away. Intention preservation adds that each edit still does what its author meant: an "X" typed after "the" stays after "the" even if text was inserted earlier in the line. An editor cap turns an unbounded problem into a bounded one.

Takeaway: Ask about concurrency per object first, then say "convergence plus intention preservation" out loud.

Capacity

Interviewer: Size it.

You: Assumptions: 20 minutes of editing per user a day; one batched op every 2 s while typing, because keystrokes are coalesced; 200 bytes per op on the wire, 100 stored; ten opens per user a day; 5B documents with 50 KB snapshots; peak three times average; two recipients per op.

Quantity Arithmetic Result
Concurrent editors 100M × 20 / 1,440 ~1.4M
Op rate 1.4M ÷ 2 s; × 3 ~700K/s average, ~2M/s peak
Opens 1B/day ÷ 86,400 ~12K/s, ~35K/s peak; 1.75 GB/s of snapshots, cacheable
Op log growth 60B ops/day × 100 B ~6 TB/day; ~11 PB over 5 years raw, ~1.5 PB after compaction, ~4.5 PB replicated
Snapshots 5B × 50 KB, doubled for history ~500 TB, ~1.5 PB replicated
Connections ~10M peak at 50K per server ~200 servers
Collab memory 3M open docs × 200 KB ~600 GB, ~20 servers by memory
Presence ~4M small messages/s In memory only, never stored

Memory says 20 servers, connections say 200 — so connections bind. Batching keystrokes is the biggest lever on the op rate; sending each one would multiply it five to ten times. Almost all load is per document, which makes partitioning by document id natural.

API

Interviewer: What does the API look like?

You: Two surfaces. REST for metadata, open, sharing, comments and history. A WebSocket for the live session, because both sides push constantly (R1).

Method Path / message Purpose
POST / GET /v1/docs, /v1/docs/{docId} Create; load latest snapshot, revision, role and a short-lived session token
GET /v1/docs/{docId}/revisions[/{rev}] History list; render at a revision
POST /v1/docs/{docId}/restore Restore — creates a new op, never rewrites history
PUT /v1/docs/{docId}/permissions ACL entries, link-sharing mode
WS wss://collab/{docId}?token= Live session
WS msg join {baseRev} / op {clientId, seq, baseRev, ops[]} Join from a revision; submit a batch
WS msg ack {seq, rev} / remote {rev, ops[], author} / presence {cursor} Confirm own op; broadcast others'; ephemeral cursor
// client -> server
{ "type": "op", "clientId": "c_77", "seq": 42, "baseRev": 1203,
  "ops": [ { "retain": 15 }, { "insert": "hello", "attrs": { "bold": true } } ] }
// server -> author
{ "type": "ack", "seq": 42, "rev": 1206 }
// server -> everyone else
{ "type": "remote", "rev": 1206, "author": "u_19",
  "ops": [ { "retain": 18 }, { "insert": "hello", "attrs": { "bold": true } } ] }

The broadcast says retain 18, not 15: the server transformed the op against revisions 1204 and 1205, which the author had not seen. Every op carries the revision it was written against; that one number is what makes merging possible.

Interviewer: The network drops right after a client sends an op.

You: Three rules. (clientId, seq) identifies a batch, so a resend after reconnect finds the seq already applied and the server just re-sends the ack (R3). Each client has at most one unacknowledged batch in flight — the Jupiter / Google Wave style — and new edits buffer and compose into the next batch. And the WebSocket uses a signed session token issued after the ACL check on open, so the collab server does not call the ACL service per op; it re-validates on refresh and on revocation events.

What's being probed: transport choice, revision-based messages, and safe retries off the hot path.

Concept — delta ops with a base revision. The client sends "keep 15 characters, insert 'hello' in bold" plus the revision it saw. The server then knows exactly which concurrent edits the client missed. One in flight keeps client logic to "one outstanding op plus a buffer"; the idempotency key makes a resend free.

Takeaway: Retry with the same (clientId, seq); the server dedups, so retries cost nothing.

Data model

Interviewer: The data model, and where each piece lives.

You:

Entity Store Key Why this store
Document metadata Spanner-style SQL or sharded Postgres docId Strong consistency, transactions with ACL changes, owner index
ACL entries Same SQL store (docId, principalId) Transactional; "shared with me" index on principal
Operation log Wide-column (Bigtable, Cassandra, HBase) partition docId, clustering rev ascending Append-heavy, ordered "ops after rev N", huge volume
Snapshots Object store plus index row (docId, rev) Large immutable blobs, cache-friendly
Comments, suggestions, named versions SQL (docId, commentId) / (docId, rev) Relational, moderate volume
Routing map etcd / ZooKeeper docId → serverId, epoch Strongly consistent leases: one owner per document
Presence Collab server memory only (docId, sessionId) Ephemeral; clients resend
row key:  d_8f2a#000000001206        (docId + zero-padded rev: lexicographic = numeric order)
columns:  author=u_19, client=c_77, seq=42, ts=2026-10-02T10:15:03Z,
          ops=<compressed delta: retain 15, insert "hello" {bold}>

Open is metadata plus ACL point reads, one snapshot blob, and one range scan for ops after the snapshot's revision. Edit is one appended row per accepted batch; the owner is the only writer, and a conditional write that rev does not already exist guards against a stale owner.

Interviewer: Why not put ops in Postgres too?

You: Sixty billion rows a day is far beyond what one SQL cluster handles cheaply, and we never join ops with anything. Every read is "ops for one doc after rev N", which is exactly the native read of a wide-column store partitioned by docId. With small volume and a need for joins, distributed SQL would be fine.

What's being probed: event log plus snapshot, and a store chosen by its native read.

Concept — ledger for truth, snapshot for speed. Store every change in order, like a ledger, and periodically write the running state. The log gives history, blame and catch-up; the snapshot keeps opens fast by bounding replay. In a wide-column store the partition key decides the machine and the clustering key the order, so docId + rev makes the hottest query one sequential read (R10).

Takeaway: Pick the store whose native read is your hottest query, with a row key that sorts in the order you read.

High-level design

Interviewer: Draw it.

You: Every document has one owning collaboration server that orders all its edits. Everything else supports opening, durability and side features. Click the collab server to see what it holds in memory, then play the concurrent-edit flow and watch the second op get transformed before it is committed.

Component Job
Editor client Full local model; local echo; one in-flight op plus a buffer; transforms incoming ops against pending ones; unsent ops in IndexedDB
WebSocket LB Holds long-lived connections; looks up the owner in the routing map; sticky by docId, not by user
Routing map Leases docId → serverId, epoch; consistent hashing picks the default owner
Collab server Per owned doc: in-memory model, single ordered op queue, revision counter, sessions. Transforms, assigns rev, persists, acks, broadcasts, relays presence
Fan-out relay For hot docs, receives each op once and pushes to thousands of viewers
Document API / ACL / comments Open, history, restore; permission evaluation with cache and revocation events; comment threads
Op log / snapshot store / metadata DB Truth for edits; immutable snapshots; docs, ACLs, comments
Op event stream → snapshot worker Every committed op published; worker snapshots every ~500 ops or on idle; search, mentions and audit consume the same stream

Interviewer: Open a document, end to end.

You:

  1. GET /v1/docs/{docId}; the Document API asks the ACL service for the role — 404 rather than 403 if none, to avoid confirming the document exists.
  2. It returns the latest snapshot (say rev 1200), the role and a signed token valid ten minutes.
  3. The client renders the snapshot at once, so the page is usable before the live session connects.
  4. The client opens a WebSocket; the LB finds the owner, or one takes a lease if none.
  5. join {baseRev: 1200}; the collab server verifies the token, loading snapshot plus log tail if the doc is not in memory.
  6. It sends ops 1201 to current, plus presence. The client is live.

What's being probed: separating the stateful real-time path from the stateless request path.

Concept — stickiness by document. Ordering every keystroke through a database is too slow, so the live document sits in one server's memory and every editor of it is routed there. The LB routes by the shared object because two users of one doc must meet on one machine. Opening is snapshot then catch-up: a fast, slightly stale picture, then "everything since 1200".

Takeaway: Route by the object being shared; show a snapshot fast, then stream the delta since its revision.

Deep dive — OT versus CRDT

Interviewer: Two people type into the same sentence at the same moment. How do they end up with the same text?

You: First, why it is a problem. Start with "abc".

They diverge — positions refer to the document each user saw

Operational Transformation rewrites an incoming op against concurrent ops applied first, so it still means the same thing:

transform(op1 = insert(p1, s1), op2 = insert(p2, s2)):   // op1 adjusted for op2 already applied
    if p1 < p2:  return op1
    if p1 > p2:  return insert(p1 + len(s2), s1)
    // same position: deterministic tie-break, e.g. lower site id first
    return (site(op1) < site(op2)) ? op1 : insert(p1 + len(s2), s1)

transform(insert(p, s), delete(dp, n)):
    if p <= dp:      return insert(p, s)
    if p >= dp + n:  return insert(p - n, s)
    return insert(dp, s)                          // insert landed inside the deleted range

The flow on the server:

  1. Doc is "abc" at rev 10. Both apply locally and send with baseRev 10.
  2. The collab server handles one op at a time per document. Alice's arrives first; base equals current, no transform; persisted as rev 11.
  3. After the durable write it acks Alice and broadcasts insert X @1.
  4. Bob's arrives with base 10, server at 11. Alice inserted before Bob's position, so Bob's shifts 2 → 3; persisted as rev 12, insert Y @3.
  5. Ack Bob; broadcast insert Y @3.
  6. Bob's client received insert X @1 while its op was unacked; it transforms against its pending op (X at 1 is before Y at 2, unchanged) and gets "aXbYc".
  7. Alice applies insert Y @3 to "aXbc" → "aXbYc". Converged.

With a central server defining one total order, the client transforms only against its own pending ops and the server only against ops since the client's base revision. That avoids the hard TP2 property peer-to-peer OT needs. Google Docs is publicly described as OT with a central server, descended from Jupiter and Google Wave.

Interviewer: Figma and newer editors talk about CRDTs. Why not skip writing transform functions?

You: A CRDT gives every character a permanent id and makes an insert refer to its neighbour by id, not index. "abc" has ids a=(1,s0), b=(2,s0), c=(3,s0). Alice inserts X as (4,A) "after (1,s0)"; Bob inserts Y as (4,B) "after (2,s0)". Any replica applying both, in any order, gets "aXbYc". Ties after the same character break deterministically — higher Lamport clock, then site id — and deletes leave tombstones so later ops still find their anchors. Yjs and Automerge implement this; Figma has described a CRDT-inspired design that still uses a central server as authority.

Aspect OT, central server CRDT
Ordering Server assigns total order (revisions) None needed; merge is commutative
Server role Required for correctness Optional; enables peer-to-peer
Metadata Small: positions only Per-character ids and tombstones; needs GC
Long offline divergence Works; long rebases cost CPU Natural fit
Implementation Transform pairs grow hard for rich text Library does the hard part; moves still tricky
History Linear revisions from the log Extra work to map to linear versions
Per-op access control Easy: server validates every op Harder in pure P2P

My choice stays OT with a central collab server per document. We need a server anyway for permissions, durability and history, so the main CRDT advantage matters less, and OT keeps ops small and revisions linear. I'd pick a CRDT for an offline-first or peer-to-peer product, end-to-end encrypted documents, or a team that would rather not own transform functions.

What's being probed: whether you understand why edits diverge, and can choose between two valid repairs.

Concept — OT and CRDTs. "Insert at 2" means something only against one document state; when another edit lands first, OT rewrites the position so applying both in either order gives the same result (TP1), with a deterministic tie-break. A CRDT avoids positions: permanent ids make merges commutative and idempotent, so replicas converge with no coordinator, paid for in per-character metadata and tombstone GC.

Takeaway: OT needs a server to order ops; CRDTs pay in metadata to not need one — choose by whether you already have a server.

Deep dive — exactly one owner per document

Interviewer: With hundreds of collab servers, how do you guarantee one ordering point per document?

You: If two servers accepted ops for one doc, they would assign the same revision to different ops.

Option How Problem
Lock in the DB per op Row lock or CAS on latestRev Latency per keystroke; hot-row contention (R11)
One global sequencer One service orders everything Bottleneck and single point of failure
Per-document owner All editors of a doc connect to one server, which orders in memory Routing and failover must be right

Per-document ownership, with R8's machinery: consistent hashing of docId picks a default owner, and the owner takes a lease in etcd or ZooKeeper — the lease, not the hash, grants ownership. The LB looks it up and forwards, or redirects. Every op-log write is a conditional put on (docId, rev) carrying the lease epoch, so a server that lost its lease in a GC pause or partition fails its next write and drops the document. Adding servers moves about 1/N of documents; a moving doc drains — stop accepting, flush, release, clients reconnect with their base rev. One document is bounded at ~100 editors, so one owner is enough; viewers go to relays.

What's being probed: leader election per key and protection against split brain.

Concept — leases and fencing. A lease is time-limited ownership from a consistent store, and on its own it is not enough: a frozen server wakes believing it still owns the document. A fencing token — an epoch that only rises, checked by the storage layer — rejects the stale writer. R8 covers this in full; the conditional put on (docId, rev) is a second fence for free.

Takeaway: A lease says who should own it; a fenced write makes sure nobody else can act as owner.

Deep dive — op log, snapshots and acks

Interviewer: When exactly do you ack, and how do you open a doc with millions of ops?

You: The op log is the truth, one row per committed batch, written before the ack, with author and timestamp — history and blame for free. Snapshots every ~500 ops or after 30 s idle, made asynchronously from the event stream, so an open replays at most ~500 ops. Revision numbers are the vocabulary for everything: base revs, acks, catch-up, comment anchors, history.

on op(clientId, seq, baseRev, ops) for doc D:              // single-threaded per document
    if dedup[clientId] >= seq: send ack(seq, revOf(clientId, seq)); return
    for r in (baseRev+1 .. D.rev): ops = transform(ops, log[r])
    newRev = D.rev + 1
    oplog.putIfAbsent((D.id, newRev), ops, epoch)           // fenced, durable
    D.apply(ops); D.rev = newRev; dedup[clientId] = seq
    send ack(seq, newRev) to author
    broadcast remote(newRev, ops) to others; publish to event stream

On a busy doc the owner groups several ops into one storage write — group commit — and acks each only after the group is durable. After 30 days, consecutive ops by one author are compacted into coarser history entries; named versions are pinned.

Interviewer: A quorum write per keystroke batch sounds slow. Why not ack on receipt?

You: The typist never waits for the ack — local echo already shows their text. The ack only tells the client it can forget the op. Acking on receipt would shave milliseconds off something nobody sees and break "acked edits are never lost" if the server crashed before writing. An in-region quorum write is a few milliseconds.

What's being probed: durability semantics, and seeing that local echo decouples perceived latency from commit latency.

Concept — persist, then ack. An ack is a promise; keeping it means the edit is on replicated storage first. The client holds every unacked op, so a crash before persisting just means a resend. Group commit makes this cheap; snapshots bound replay; compaction bounds history growth.

Takeaway: Persist, then ack; local echo means users never feel the wait.

Deep dive — presence, offline, comments and sharing

Interviewer: Cursors without flooding the system?

You: The client throttles to one update every 100–200 ms, tagged with the revision it refers to. The server keeps it in memory, transforms the position against ops since that revision — a cursor is a zero-length insert — and broadcasts to editors, batched to relays. Clients transform remote cursors locally as ops arrive. No heartbeat for ~30 s means "user left". Viewers see "+312 viewing", not every cursor. Nothing is persisted.

Interviewer: Alice edits on a plane for three hours.

You: Her client stores the document, its last server rev (50) and its pending ops in IndexedDB, composing them where possible. On reconnect it sends join {baseRev: 50} and its pending batches with their original (clientId, seq). The server sends her ops 51–80, transforms her pending ops against them and commits them as new revisions — re-acking any seq that landed before the disconnect. Her client transforms 51–80 against its pending ops and both sides converge. It is git rebase with the live-editing transforms. For a very long gap the cost is O(n × m), so compose the pending queue first, or rebase once onto a fresh snapshot. If she lost edit rights meanwhile, the ops are rejected and the client offers to copy her text. Two people rewriting one sentence still merge mechanically; history lets them undo.

Interviewer: Comments on text that keeps moving? Suggestions, history, sharing?

You: Everything is an op.

What's being probed: separating ephemeral from durable state, and reusing one mechanism for many features.

Concept — offline as a long gap, features as ops. Offline reconciliation is the same transform applied both ways over a bigger gap, and composing first keeps it cheap. Comment anchors, suggestions, restores and undo are all ops on the same log, so they inherit ordering, history and concurrency handling without new machinery. Presence is the opposite case: valuable only now, so throttled, in memory, and allowed to be lost.

Takeaway: Build features as ops on the same log; keep presence in memory and let it go.

Deep dive — one hot document

Interviewer: The CEO shares the all-hands doc. 50 editors, 20,000 viewers.

You: Viewers are the problem, not editors. Cap editors at ~100. The owner sends each committed op once to a handful of fan-out relays, each pushing to thousands of viewer sockets — a two-level tree. Presence for viewers is aggregated. Broadcasts to viewers batch every 100–250 ms. Viewers that fall behind skip ahead with a fresh snapshot. For a published doc with a huge audience, serve a periodically refreshed render through a CDN. With moderate viewer counts, a pub/sub topic per doc would do instead of relays. Keep one writer; spread reads.

Scale and failure

Interviewer: A collab server owning 15,000 documents crashes.

You: The lease expires in 5–10 s or a health checker releases it. A new owner takes the lease with a higher epoch and loads snapshot plus log tail. Clients reconnect with their base rev and resend unacked ops, idempotent by (clientId, seq). No acked op is lost, because acks follow durable writes. Users keep typing locally through the gap. Clients reconnect with jittered backoff (R14), and the new owner loads each doc once and serves every join from memory. The server's memory is a cache of durable state, so a crash costs seconds of reconnecting, not data.

Bottleneck / failure Mitigation
Zombie owner after a pause or partition Fenced conditional put on (docId, rev) with epoch
Op-log latency on the hot path In-region quorum; group commit; local echo
Hot document Editor cap, relays, batched broadcasts, aggregated presence, CDN render
Reconnect herd Jittered backoff; load once, serve joins from memory
Slow opens on long tails Snapshot every ~500 ops or on idle; snapshot cache; render before the socket connects
Presence storms; long offline rebases Throttle, coalesce, never persist; compose pending ops, snapshot rebase, per-doc CPU limits
ACL cost; op-log growth ACL cache with event invalidation and tokens; compaction after 30 days, tiered storage

Interviewer: Global users. A region goes down.

You: Each document has a home region holding its owner and authoritative log; distant users connect there and local echo hides the extra round trip. The home can move toward where most editors are, using drain-and-hand-off. The log and snapshots replicate asynchronously, so RPO is replication lag — and clients still hold unacked ops, so most of that gap is resent on reconnect. Metadata and ACLs sit in a globally consistent store, so a revocation holds everywhere. Multi-leader with a CRDT would give local commit latency on every continent, at a lot of complexity for little visible gain.

What's being probed: a complete failure story, and keeping one total order under global distribution.

Concept — reconstructible state and a home region. Make every in-memory structure rebuildable from the log plus what clients hold, and recovery is mechanical. One ordering point must live somewhere, so pick a home region; permissions are the exception that must be strongly consistent everywhere, or a revoked user keeps access in one region (R13).

Takeaway: Keep memory as a cache of durable state; one home region per document keeps ordering simple.

Curveballs

Interviewer: Why not just lock the paragraph someone is editing?

You: A lock needs a round trip before typing, which kills local echo, blocks people, and leaves "which paragraph" unclear for selections that span several. Two people rarely type into the exact same spot, so optimistic concurrency with a guaranteed repair step wins.

Interviewer: How does undo work with several editors?

You: Undo reverses my last op, not the document's last op. The client keeps the inverse of each of its own ops, transforms it past everything since, and submits it as a new op. If I typed "hello" and someone then typed before it, my undo deletes "hello" at its new position and their text stays.

Side features hang off the op stream, not the hot path: search re-indexes after a debounced burst from the latest snapshot, and mentions and audit consume the same events. A 10 MB doc loads the visible part first and snapshots more often.

What's being probed: whether the core mechanisms answer each question in one breath.

Concept — optimism, selective undo and bounded transform work. Optimistic concurrency applies now and repairs later, which is right when conflicts are rare and waiting feels broken. Undo is a new op inverting your own edit. Neither side ever transforms against the whole history — only the unseen gap.

Takeaway: Editors are optimistic; transform only against the unseen gap; undo is a new op.

Quick reference

Decision Choice Why Alternative and when
Conflict resolution OT with central server Server needed anyway; small ops; linear revisions CRDT for offline-first, P2P or end-to-end encrypted docs
Ordering point One owner per document, leased and fenced In-memory ordering, no per-op DB locks DB compare-and-set per op for a low-traffic MVP
Persistence Op log + snapshots every ~500 ops Fast open, full history, cheap catch-up Snapshot-only (no fine history) or log-only (slow opens)
Op-log store Wide-column, docId + rev Ordered per-doc scans at huge volume Distributed SQL at small volume
Hot-path permissions Signed token + revocation events No ACL call per keystroke Per-op check for instant revocation
Viewer fan-out Relay tree Bounds owner load Pub/sub per doc for moderate audiences
Multi-region One home region per doc Simple total order Multi-leader CRDT for local commit everywhere

The 60-second version

Clients hold a full local copy and apply their own edits instantly; edits travel as small ops over a WebSocket, each with the revision it was based on. Every document has exactly one owning collab server, chosen by consistent hashing and held with a fenced lease, so ops get one total order. The owner transforms each op against what the client had not seen, assigns the next revision, writes it durably, then acks and broadcasts; clients transform incoming ops against their pending ones, and everyone converges. Storage is an append-only op log in a wide-column store plus periodic snapshots; open is snapshot plus a short replay, and the log is the version history. Presence is in memory and transformed like a zero-length insert. Offline edits queue with (clientId, seq) and rebase on reconnect. A crashed owner is replaced in seconds and nothing acked is lost. CRDTs are the answer when there is no central server or offline is the main mode.


Walkthrough 3 — Stock broking: live prices, orders and risk

Building on WhatsApp and Google Docs: WhatsApp showed how to hold millions of connections; Google Docs showed how to give each shared object one owner that orders every change. A broker needs both at once — millions of screens receiving prices, and one owner per account deciding, in order, whether each new order can be afforded. It adds two things neither had: money that must never be double-spent, and recovery that has to be instant.

Scope and requirements

Interviewer: Design a retail stock broking platform — the app people use to watch prices and buy and sell shares.

You: A broker sits between customers and the stock exchange. Customers never talk to the exchange directly: the broker checks each order, sends it to the exchange, and reports back what happened. Before designing, I'd pin down a few things:

# Question Why it matters
1 Are we the broker or the exchange? A broker routes orders; an exchange matches them. Different core problems
2 Which products — shares only, or derivatives too? Derivatives bring margin and leverage, so the risk check gets harder
3 How many users, and how many connected at market open? The open is the peak for both prices and orders
4 How fresh must prices be on screen? Decides whether we send every price change or the latest every few hundred milliseconds
5 Which order types — market, limit, stop-loss? Stop-loss may need triggers that we hold and watch ourselves
6 Who connects to the exchange — us directly, or through another member? Decides whether we run exchange sessions ourselves

Interviewer: You're the broker. Shares and derivatives, 12 million customers, up to 2 million connected at the open. You connect to the exchanges directly.

You: Then the requirements.

Functional:

Non-functional:

Two very different workloads share one app: a firehose of prices where only the latest value matters, and a trickle of orders where every single one must be exact. I'll design them separately.

What's being probed: whether you clarify broker versus exchange, and spot that one product hides two workloads.

Concept — split by the cost of a wrong answer. A stale price on screen for 200 ms costs nothing; the next update fixes it. A lost or duplicated order costs real money and a regulatory report. So the price path can drop intermediate values and the order path cannot drop anything. Amazon (R19) applies the same idea to a cart versus a stock count. Saying it early lets every later choice be justified by one sentence.

Takeaway: When one product has a lossy-tolerant path and an exact path, design them as two systems.

Capacity

Interviewer: Size it.

You: Assumptions: 2M connections at the open; 20 instruments per watchlist on average; about 5,000 instruments actively trading; 15M orders a day; each order produces about 5 events (accepted, sent, acknowledged, one or two fills); continuous trading lasts 6¼ hours, about 22,500 seconds.

Path Average Peak
Orders 15M ÷ 22,500 ≈ 670/s ~10× in the first minute of the open → ~7K/s
Order events ~3,300/s ~35K/s
Exchange price updates in tens of thousands/s ~200K/s across all instruments at the open
Price frames out — 2M clients × 4 frames/s = 8M frames/s

The price numbers need one more step. If we forwarded every exchange update to every interested screen, a popular stock changing 200 times a second, watched by 500,000 people, would alone produce 100 million messages a second. Instead, each connection gets one frame every 250 ms containing only the instruments that changed. That is 2M × 4 = 8M frames a second. At about 8 changed instruments per frame and 40 bytes each, roughly 320 bytes per frame, the total is about 2.6 GB/s — around 21 Gbps across the fleet. With 200 gateways holding 10,000 connections each, every gateway sends about 40K frames and 100 Mbps a second. That is easy.

Storage: order events 15M × 5 × 500 B ≈ 37 GB a day, kept for years. Risk state is tiny: 12M accounts × ~1 KB = 12 GB, which fits in memory across a handful of machines.

The order path is small and must be exact; the price path is huge and only needs to be fresh.

What's being probed: whether the arithmetic changes the design.

Concept — fan-out arithmetic and conflation. Multiply update rate by subscribers before deciding how to deliver. The raw product is impossible; batching per connection per time slot makes it linear in the number of clients. Conflation means "keep only the latest value per key until the next send". It is safe for prices and wrong for chat messages, where every message must arrive. R21 looks at fan-out in general.

Takeaway: Size fan-out as rate × subscribers, then conflate whatever only needs its latest value.

API

Interviewer: Show me the API.

You: Two channels. One WebSocket per app carries prices and order updates; REST carries commands.

POST /v1/orders
Idempotency-Key: 2f6c1e0a-7d4b-4a51-9b1e-5c2d9a7e3f10
{ "exchange": "NSE", "instrument": "INFY", "side": "BUY", "quantity": 10,
  "orderType": "LIMIT", "price": 152050, "product": "DELIVERY", "validity": "DAY" }

HTTP/1.1 202 Accepted
{ "orderId": "250912000184321", "status": "RECEIVED" }

On the WebSocket, the client subscribes by instrument and mode:

{ "action": "subscribe", "mode": "QUOTE", "instruments": [408065, 738561] }

Modes are last price only, a quote (last price, volume, day's high and low), or full depth. Frames are binary, not JSON, because at 8M frames a second every byte matters. Order updates travel on the same socket, routed to the user like WhatsApp messages.

Data model

Interviewer: Data model, and where each piece lives.

You:

Data Store Key Notes
Instrument master Loaded daily from exchange files; in memory everywhere Instrument token Symbol, lot size, tick size, price band
Orders and order events Postgres, sharded by account; events append-only orderId; (orderId, seq) Every state change is a new row — the audit trail
Risk state per account In memory on the risk shard that owns the account Account Available cash and margin, amounts blocked per open order
Risk input journal Replicated append-only log Shard, sequence The durable truth for risk state; replayed after a crash
Trades and positions Postgres, fed from order events Account, date Positions are derived from trades
Holdings Postgres, refreshed from the depository after settlement Account, ISIN Shares actually owned; India settles trades on T+1
Latest prices In memory on feed handlers and gateways Instrument Rebuilt from the feed; never the source of truth for orders
Candles (1-minute bars) Time-series store Instrument, minute Built from the feed for charts
Outbound exchange messages Local durable log per exchange session Session, sequence number Needed to answer the exchange's resend requests

Notice what is not in a database: the risk state. It lives in memory, and its durability comes from the journal it is built from — the move R18's introduction called "persist before acknowledging, then replay".

High-level design

Interviewer: Draw it.

You: Two flows share the client and the WebSocket, and nothing else. Play the order flow, then the price flow, then the failover.

Component Job Stateful?
Client app Watchlist, order ticket, positions —
API gateway Auth, rate limits per user, idempotency check No
Order service Validates the order (tick size, price band, lot size), assigns orderId, records RECEIVED No
Risk shards Each owns a slice of accounts in memory; checks and blocks margin one command at a time Yes — journal + hot standby
Exchange gateways Hold the sessions with each exchange; send orders, receive acknowledgements and fills Yes — sequence numbers per session
Feed handlers Receive the exchange's price broadcast, fix gaps, normalise Yes, rebuildable
Tick gateways Hold WebSockets; keep each client's subscriptions; send conflated frames Yes, rebuildable
Session registry user → tick gateway, so order updates reach the right socket Yes, rebuildable
Event stream → back office Order events to positions, ledger, notifications, contract notes, end-of-day reconciliation No

An order, end to end:

  1. The app sends POST /v1/orders. The gateway checks the idempotency key; the order service validates the order against the instrument master and writes RECEIVED.
  2. The order service sends a PlaceOrder command to the risk shard that owns the account. The command is appended to that shard's replicated journal before anything else happens.
  3. The shard checks available margin and blocks the amount the order needs, then emits Accepted — or Rejected: insufficient funds.
  4. The exchange gateway sends the order on one of its exchange sessions, after writing the outbound message to its session log.
  5. The exchange acknowledges with its own order number; later it sends fills. Each fill flows back to the risk shard (turning blocked margin into used margin), to the order events table, and to the user's socket.

What's being probed: separating the stateless request path from the stateful components, and naming what each stateful part holds.

Concept — stateless around a stateful core. Everything that can be stateless is: the gateway and order service scale by adding copies. Only three parts hold state, and each holds a different kind. Risk shards hold exact money state, with replay for durability. Exchange gateways hold protocol state that must survive a reconnect. Tick gateways hold disposable state that a client can rebuild by resubscribing. Saying which kind each is tells the interviewer you know how each one recovers.

Takeaway: Keep the stateful parts few, and classify each by how it recovers: replay, resync or rebuild.

Deep dive — live prices to two million screens

Interviewer: Walk me through a price, from the exchange to a phone.

You: Four hops.

  1. The exchange broadcasts. Exchanges publish prices by UDP multicast — one packet sent once, received by every subscriber on the network — usually on two identical lines, A and B, for redundancy. Every packet carries a sequence number.
  2. Feed handlers fix the stream. They read both lines, keep the first copy of each sequence number and drop the duplicate. If a number is missing on both lines, that is a gap: they request a retransmission, or rebuild from the exchange's periodic snapshot, and meanwhile mark the affected instruments "stale" rather than show a wrong price. Then they normalise each update into our format, keyed by instrument.
  3. An in-memory bus fans out to gateways. Each tick gateway subscribes only to the instruments its own clients want. It is a fan-out tree: the feed handler sends INFY once to each interested gateway, not once per screen.
  4. Tick gateways conflate and send. Each connection has a small table — the latest value of each instrument it subscribes to — and a "changed" flag per entry. Every 250 ms the gateway sends one frame per connection with only the changed entries, then clears the flags.

That last step is where the scale problem disappears. A slow phone on a weak signal never builds a backlog: if it misses a send, the next frame simply carries newer values. For prices, the latest value replaces every older one — so drop old prices, never connections.

When a client connects or subscribes, it first gets a snapshot of current values, then deltas. When a gateway dies, its clients reconnect elsewhere with jitter, resubscribe and get a fresh snapshot. Nothing is lost, because nothing on the gateway was ever the truth.

What's being probed: fan-out without unbounded queues, and recovery of disposable state.

Concept — latest-value semantics versus every-message semantics. WhatsApp must deliver every message, so a slow device needs a durable inbox. A price feed only needs the newest value, so a slow device needs nothing but one slot per instrument. Choosing the semantics first decides the whole design: inbox and acknowledgements, or conflation and snapshots. R14's back-pressure options are drop, buffer or slow the sender; for prices, dropping stale values is the right one.

Takeaway: Decide whether each message matters or only the latest value does; conflate the second kind.

Deep dive — the risk check, one owner per account

Interviewer: A customer has ₹1 lakh and sends two ₹80,000 orders a millisecond apart. Stop the second one.

You: This is the same race as two people booking the last seat (R19), and the same rule applies: the check and the update must be one step. There are two ways to make it one step.

Option 1 — a database row lock per account. UPDATE funds SET blocked = blocked + 80000 WHERE account = ? AND available - blocked >= 80000. It is correct, and right for a small broker. At the open, though, active traders send dozens of orders a second, every order pays a database round trip, and a busy account's row becomes a queue.

Option 2 — one owner per account, in memory. Accounts are hashed to risk shards. Each shard is a single thread that takes commands one at a time from its journal and owns its accounts outright. Two orders for the same account are simply processed one after the other — no locks, because no other thread ever touches that account. This is R13's single-writer principle; the LMAX exchange made the pattern well known.

// one thread per shard; the journal has already made each command durable
while (running) {
    Command c = journal.next();
    switch (c) {
        case PriceUpdate p -> prices.put(p.instrument(), p.price());   // prices enter as commands too
        case PlaceOrder o -> {
            Account a = accounts.get(o.account());
            long need = margin.required(o, prices.get(o.instrument()));
            if (a.available() < need) emit(new Rejected(o.orderId(), "INSUFFICIENT_FUNDS"));
            else { a.block(o.orderId(), need); emit(new Accepted(o.orderId())); }
        }
        case Fill f -> accounts.get(f.account()).applyFill(f);         // blocked → used
        case Closed x -> accounts.get(x.account()).release(x.orderId()); // cancel or reject: unblock
    }
}

The first order blocks ₹80,000, leaving ₹20,000 available, so the second is rejected. Blocking margin is itself a reservation: block on order (try), keep it as used margin on a fill (confirm), release it on a cancel or rejection (cancel). It is R2's try–confirm–cancel, and the whole of R19.

Interviewer: The state is in memory. The machine dies at 09:15:02.

You: That is why every command goes into a replicated journal before the shard processes it. Recovery is "load the last snapshot, replay the journal since". Replay takes seconds, which is too slow at the open. So each shard has a hot standby: a second machine reading the same journal and running the same code, holding an identical copy at all times. When the primary's lease lapses, the standby takes over with a new epoch (R8), and old-epoch outputs are refused downstream. Failover takes well under a second.

The standby only stays identical if the shard is deterministic. Same commands in the same order must give exactly the same state:

Deterministic logic plus a replicated input log gives you a hot copy for free — the replica does not copy state, it recomputes it.

What's being probed: single-writer concurrency, durability of in-memory state, and why determinism matters.

Concept — state machine replication. If a program is deterministic, its state is a pure function of its input history. So you replicate the inputs — a log — rather than the state, and any number of machines replaying the log hold the same state. Raft and Paxos (R8) are ways of agreeing on that log; the matching engines of exchanges, and Google Docs' op log, use the same idea. The cost is discipline: one non-deterministic line, such as reading the clock, silently makes the replicas diverge.

Takeaway: Replicate the input log, not the state, and keep the logic strictly deterministic.

Deep dive — talking to the exchange

Interviewer: How do orders reach the exchange, and what if the connection drops mid-order?

You: Each connection to the exchange is a session — FIX, the financial industry's standard message protocol, or the exchange's own binary protocol — over leased lines. A session is stateful in a specific way: every message in each direction carries a sequence number that goes up by one. Both sides track the last number they saw. Heartbeats prove the line is alive.

Now the drop. We send an order, and the connection dies before the acknowledgement arrives. Did the exchange receive it?

A dropped connection mid-order is an unknown outcome — never resend it as a new order

Resending blindly could buy the shares twice. Instead:

  1. Reconnect and log on. The session resynchronises sequence numbers: any of our messages the exchange missed are resent, flagged as possible duplicates, and any of its messages we missed are resent to us.
  2. If the order's fate is still unclear, ask: an order-status request by our client order id.
  3. As a backstop, the client order id is unique for the day, so a true duplicate would be rejected by the exchange anyway.

That is R0's unknown outcome and R3's idempotency key, applied to a protocol designed decades before either name was common.

Interviewer: And fills?

You: The exchange sends an execution report for each partial or full fill. The exchange gateway turns each one into a Fill command for the account's risk shard, an order event in Postgres, and a push to the user's socket through the session registry — exactly how WhatsApp finds a device. Fills carry the exchange's trade id, so a replayed fill is recognised and applied once.

Deep dive — if they mean the exchange: the matching engine

Interviewer: Suppose I'd said "design the exchange". What is different?

You: The core becomes the matching engine, and it is the purest example of this section's idea.

For each instrument the engine keeps an order book: buy orders (bids) sorted from highest price down, sell orders (asks) sorted from lowest price up. The highest bid and lowest ask are the best prices; the gap between them is the spread. Matching follows price-time priority:

Data structures: price levels in a sorted structure (or an array indexed by tick, since prices are discrete), a first-in-first-out queue of orders at each level, and a map from order id to its queue position, so a cancel takes constant time.

The engine runs one thread per instrument group. A sequencer stamps every incoming order with a global sequence number and journals it. The engine is deterministic, so replicas replaying the journal hold identical books — the same state machine replication as the risk shards. Market data is simply the stream of changes to the book, published by multicast.

Step through the book below: a resting order joining the queue, a sell order matching in time order, a buy that takes the better price first, cancels and modifies, a market order sweeping several levels, and a crash recovered by replay.

What's being probed: whether you know how an order book matches, and why exchanges run single-threaded deterministic engines.

Concept — the order book as a state machine. Price-time priority makes matching a pure function: given the book and the next order, the trades and the new book are fully determined. That is why one thread per instrument is not a limitation but the design — it removes locks and makes replication a matter of replaying inputs. Fairness also falls out of it: whoever the sequencer stamps first is served first.

Takeaway: Price first, then time; one thread per instrument; replicate by replaying the sequenced input.

Failures and scale

Interviewer: What breaks at the open, and how does it recover?

You:

Failure or pressure What happens Mitigation
The open: orders and logins spike 10× normal order rate within a minute Pre-scaled fleet; overnight orders released through per-session token buckets; journal absorbs bursts
Tick gateway dies 10,000 clients lose their socket Reconnect with jitter to another gateway; resubscribe; snapshot then deltas
Feed line A drops packets Gaps on one line Line B fills them; if both miss, retransmission or snapshot, and the instrument shows "stale" meanwhile
Risk shard primary dies Orders for its accounts pause Hot standby already identical; takes over with a new epoch in under a second
Exchange session drops mid-order Unknown outcome for in-flight orders Resync sequence numbers; status query by client order id; never resend as new
Postgres or the event stream is slow Positions and history lag The order path does not wait on them; the journal is the truth and consumers catch up
A hot stock hits its price band Flood of orders at the band price Validate price bands before the risk check; reject out-of-band orders cheaply
Data centre loss Everything in one site stops Journal replicated to a disaster-recovery site; controlled failover; exchange sessions re-established from there

Each stateful part recovers in its own way: risk shards by replay, exchange sessions by resync, gateways by rebuild. The mitigations table is just those three verbs applied.

Curveballs

Interviewer: The customer double-taps "Buy".

You: Both taps carry the same idempotency key, so the second returns the first orderId. Even if a bug let a second order through, it would carry the same client order id, and the exchange would refuse it.

Interviewer: Stop-loss orders — who watches the price?

You: Two ways. The exchange can hold a stop-loss order and trigger it itself, which is simplest and the default. For triggers the exchange does not support, such as a trigger that lasts for months, the broker holds them. Then it is a scheduler driven by price instead of time (R17). Triggers are indexed by instrument and price, one owner per instrument evaluates them on every price update, and a fired trigger becomes a normal order through the normal risk check, with an idempotency key derived from the trigger id so it can never fire twice.

Interviewer: The market-wide circuit breaker trips and trading halts.

You: The exchange stops matching and rejects new orders. We stop sending, keep accepting cancels, tell users clearly, and do not queue new orders silently. Re-open is a smaller version of the morning open, with the same token buckets and pre-scaling.

Interviewer: How do you know your records match the exchange's?

You: End-of-day reconciliation: compare every trade in our order events with the exchange's trade file and the clearing corporation's obligations, and investigate every break. That is R6 and R7. Real-time correctness is necessary, but the daily reconciliation is what proves it.

Quick reference

Decision Choice Why
Price delivery Multicast → feed handlers → fan-out tree → tick gateways Each update sent once per hop, not once per screen
Slow clients Conflate: latest value per instrument, one frame per 250 ms Bounded memory; no backlog
Risk check Single-threaded shard owning each account, in memory No locks; microseconds; strict order per account
Risk durability Replicated input journal, snapshot plus replay Memory is rebuilt from inputs
Failover Deterministic hot standby, epoch fencing Under a second, no divergence
Duplicates Idempotency key = client order id, unique per day Refused by us and by the exchange
Exchange link Stateful sessions, sequence numbers, log before send Gaps are resent; outcomes resolved, not guessed
Burst control Token bucket per exchange session Never exceed exchange rate limits
Matching (if exchange) Order book, price-time priority, one thread per instrument Deterministic, fair, replicable
Proof End-of-day reconciliation with exchange and clearing Catches anything real time missed

The 60-second version

A broker has two workloads. Prices are a firehose where only the latest value matters: the exchange multicasts on two lines, feed handlers keep the first copy of each sequence number and repair gaps, an in-memory bus fans out to tick gateways, and each gateway sends every connection one conflated frame per 250 ms with only the changed instruments. Gateways hold nothing that cannot be rebuilt by resubscribing. Orders are a trickle where every one must be exact: an idempotency key that doubles as the exchange's client order id, a pre-trade risk check on a single-threaded shard that owns the account in memory, margin blocked as a reservation, every command written to a replicated journal first, and a deterministic hot standby that takes over in under a second. Exchange sessions log every message before sending, resync by sequence number after a drop, and resolve unknown outcomes by asking, never by resending. Fills flow back to risk, the order history and the user's socket, and the day ends with reconciliation against the exchange. If the question is the exchange itself, the core is a price-time-priority order book on one thread per instrument, replicated by replaying its sequenced input.


Walkthrough 4 — Zoom: video calls, WebRTC and media servers

Building on WhatsApp and stock broking: WhatsApp held connections and kept the truth in a durable inbox; stock broking showed that a price feed can drop old values as long as the newest one arrives. Here the state is a live media session held by a media server — who is sending which streams, at what quality, to whom. And unlike every earlier walkthrough, late data is worthless: a message is retried until it arrives, an order is resolved until its fate is known, but a video frame that arrives after its moment has passed is simply dropped. That one fact changes the transport, the servers, the failure handling and the sizing.

Scope and requirements

Interviewer: Design a video conferencing service like Zoom.

You: I'll design with standard WebRTC — the browser's built-in way to send real-time audio and video — because it is public and well understood. A real product may use its own protocol in its native apps, but the architecture is the same. First, some questions:

# Question Why it matters
1 How large are meetings — typical and maximum? A 4-person call and a 1,000-person meeting need different server topologies
2 Do we need webinars: a few speakers, many viewers? Many receivers and few senders lets us build a distribution tree
3 Where are users — one country or worldwide? Distance adds delay we cannot remove; decides where media servers sit
4 Screen share, chat, recording? Each is a separate path with different rules
5 Is end-to-end encryption required? It stops the server from seeing media, which rules out server-side recording and mixing
6 Browser, desktop and mobile, including weak mobile networks? Decides how hard we must work on adapting quality
7 Phone dial-in? Needs a gateway that converts between telephone audio and our media

Interviewer: Meetings average about 5 people, up to 1,000. Webinars up to 50,000 viewers. Users worldwide, heavily in India, Europe and the US. Screen share, chat and cloud recording, yes. End-to-end encryption as an option. All platforms. Ignore dial-in.

You: Then the requirements.

Functional:

Non-functional:

Media is useful only while it is fresh: a packet that would arrive after its playout moment is worth less than nothing, so this system is built to drop data on purpose, never to queue it.

What's being probed: whether you see that real-time media has a delivery rule unlike anything earlier in the pack.

Concept — deadline-bound data. Most systems treat a lost message as a problem to fix by retrying: WhatsApp's inbox, a broker's order resend. Live media has a deadline. Audio is played every 20 ms; a packet that arrives 300 ms late cannot be used, and waiting for it would freeze everything behind it. So the system's job is to deliver as much as possible before the deadline and to give up gracefully after it. Stock broking's conflation (earlier in R18) was a softer version: there, a newer price replaced an older one; here, a late frame is simply skipped.

Takeaway: For live media, prefer losing data to delaying it.

Capacity

Interviewer: Size it.

You: These are assumptions, not any real company's numbers. Peak is a weekday top of the hour, when many meetings start at once.

Quantity Assumption
Concurrent participants at peak 10 million
Average meeting size 5, so 2 million concurrent meetings
Audio per stream 40 kbps, including packet overhead
Video layers a camera sends 720p at 1.2 Mbps, 360p at 0.4 Mbps, 180p at 0.12 Mbps
Media packet size about 1,200 bytes
Average session length 40 minutes
Usable egress per media server 10 Gbps, planned at 60%

First, the media traffic, assuming each camera sends all three quality layers at once (simulcast, explained in a deep dive) and each viewer in a 5-person meeting gets one large picture and three small ones.

Upload per participant  = 1.2 + 0.4 + 0.12 Mbps video + 0.04 audio   ≈ 1.8 Mbps
Download per participant (5-person meeting, speaker view):
    1 large tile at 720p         = 1.20 Mbps
    3 small tiles at 180p        = 0.36 Mbps
    4 audio streams × 0.04       = 0.16 Mbps
                                 ≈ 1.7 Mbps

Total into media servers  = 10M × 1.8 Mbps ≈ 18 Tbps
Total out of media servers = 10M × 1.7 Mbps ≈ 17 Tbps

Now, how many media servers:

Participants per server by egress = 10 Gbps ÷ 1.7 Mbps ≈ 5,900
Planned at 60%                    ≈ 3,500 participants ≈ 700 five-person meetings
Servers at peak                   = 10M ÷ 3,500 ≈ 2,900
With per-region spare capacity    ≈ 4,000 servers across regions

Packet rate per participant = (1.8 + 1.7) Mbps ÷ (1,200 B × 8 bits) ≈ 365 packets/s
Per server                  = 3,500 × 365 ≈ 1.3 million packets/s

The packet rate matters more than the megabits. Each packet is decrypted on the way in and encrypted again for each receiver on the way out, so the server's CPU cost is per packet — and it never decodes a single video frame.

Signalling — the control messages for joining, muting and so on — is tiny by comparison:

Average joins     = 10M ÷ 2,400 s ≈ 4,200 joins/s
Top of the hour   : 30% of peak users join within 3 minutes = 3M ÷ 180 s ≈ 17,000 joins/s
Messages per join ≈ 10 (join, offer, answer, candidates, subscriptions)
Peak signalling   ≈ 170,000 messages/s, over 10M WebSockets
At 100,000 sockets per gateway (the WhatsApp figure) ≈ 100 gateways

Two more numbers shape the design:

Relay (TURN) traffic: assume 5% of users are on networks that block UDP
    500,000 × (1.8 + 1.7) Mbps ≈ 1.75 Tbps through relays — about 10% of media

Recording: assume 2% of meetings record; composite at 1.5 Mbps
    2M × 2% = 40,000 recordings
    per hour = 1.5 Mbps × 3,600 s ÷ 8 ≈ 675 MB
    storage  = 40,000 × 675 MB ≈ 27 TB per peak hour

Therefore:

What's being probed: whether the numbers drive the topology, and whether you size packets as well as bits.

Concept — bandwidth and packets, not compute. In video calls the expensive resources are network egress and packets per second, because a forwarding server touches every packet but never decodes one. Sizing in megabits alone hides the per-packet encryption cost; sizing in packets alone hides the network card limit. Doing both tells you how many participants a server holds, and therefore whether a meeting fits on one. R21 sizes fan-out the same way.

Takeaway: Size media servers by egress and packet rate, then check that a typical meeting fits on one.

API

Interviewer: What does the API look like?

You: Three channels. REST for meeting management and joining. A WebSocket for signalling — the control conversation. And the media itself, which does not go through any API: it flows as encrypted UDP packets directly between the app and a media server.

Endpoint Purpose
POST /v1/meetings Create or schedule a meeting; Idempotency-Key so a retry does not create two
POST /v1/meetings/{id}/join Check passcode and waiting room; return a join token, signalling URL and relay credentials
GET /v1/meetings/{id}/recordings List recordings after the meeting
wss://…/signal Join, SDP offer and answer, ICE candidates, subscriptions, mute, hand raise, migrate
POST /v1/meetings/842-1173-905/join
{ "displayName": "Alice", "clientSessionId": "c7d1e9a2-4b6f-4e0b-8d3a-1f5c2b7e9a40" }

HTTP/1.1 200 OK
{ "participantId": "p_8f21",
  "signalling": "wss://sig-bom.example.com/signal",
  "joinToken": "eyJhbGciOi...",
  "iceServers": [
    { "urls": "stun:stun-bom.example.com:3478" },
    { "urls": ["turn:turn-bom.example.com:3478?transport=udp",
               "turns:turn-bom.example.com:443?transport=tcp"],
      "username": "1760004000:p_8f21", "credential": "Zm9vYmFy..." } ] }

On the signalling socket, after joining, the server tells the client which media server to use and with which epoch — the ownership number explained later:

{ "type": "joined", "meetingId": "842-1173-905", "sfu": "sfu-bom-1.example.com",
  "epoch": 7, "participants": [ { "id": "p_3a10", "name": "Bob", "tracks": ["audio", "cam"] } ] }

Data model

Interviewer: What is stored where?

You:

Data Store Key Notes
Meetings and schedules Postgres meetingId Host, settings, passcode hash, waiting room on or off
Live meeting state In memory on the meeting controller that owns the meeting; changes journalled to a replicated log meetingId Roster, roles, who is muted, who is sharing; a few kilobytes
Meeting → media server assignment Coordination store with leases (etcd or ZooKeeper style) meetingId, region Server id, lease expiry, epoch
Media session state Media server memory only Participant, track Stream ids, encryption keys, chosen quality layer, bandwidth estimate, a short packet history
Media server load Heartbeats into the allocator Server id Participants, egress, CPU, region
Chat messages Durable log, e.g. Cassandra meetingId, sequence Separate path; must never be lost
Recordings Object storage; metadata in Postgres recordingId Uploaded in segments; composited after the meeting
Relay credentials Not stored — HMAC of an expiry time; any relay can verify

Nothing on the media path is ever written down: a meeting's durable state is a few kilobytes of control data, and the megabits a second are forwarded and forgotten. That is why a media server's crash costs a few seconds of picture, not data.

High-level design

Interviewer: Draw it.

You: Two planes. The signalling plane decides who is in the meeting and where their media goes; the media plane moves the packets. Play the join first, then media with quality layers, then the bad network, the failover and the webinar.

Component Job
Client app Captures, encodes and sends audio and video; receives, buffers, decodes and plays
Signalling gateway Holds one WebSocket per client; authenticates; routes control messages to the meeting's controller
Meeting controller Owns each meeting's live state (roster, roles, mutes); chooses a media server; relays offers and answers
Meeting registry meeting → media server with a lease and epoch, so there is one owner
STUN / TURN STUN tells a client its public address; TURN relays media for clients that cannot send UDP directly
SFU (media server) Selective Forwarding Unit: receives each stream once and forwards a chosen copy to each receiver, without decoding
Cascaded SFUs SFUs in other regions that receive one copy of each stream over the backbone and serve local participants
Chat service Durable, ordered chat per meeting, delivered over the signalling socket
Recorder Joins as a hidden participant, writes streams to object storage

A join, end to end:

  1. Alice opens the link. POST /join checks the passcode and returns a token and relay credentials. Her app opens the signalling WebSocket to the nearest gateway.
  2. The gateway sends join to the meeting controller that owns meeting 842-1173-905 (meetings are hashed to controllers). The controller looks up the registry: the meeting is on sfu-bom-1, epoch 7. If it were the first join, the allocator would pick the least-loaded SFU near Alice and create the entry.
  3. Alice's app asks the STUN server for her public address, and creates an SDP offer describing what she wants to send and receive. The offer goes over signalling to the SFU, which replies with an SDP answer.
  4. Alice's app and the SFU run ICE connectivity checks to find a working network path, then a DTLS handshake to agree on encryption keys.
  5. Encrypted media flows over UDP: Alice sends her camera in three quality layers; the SFU forwards each other participant's best-fitting layer to her.
  6. The controller tells everyone else "Alice joined, with these tracks", and their apps subscribe to her streams.

What's being probed: separating control from data, and knowing that the media server is a forwarder.

Concept — control plane and data plane. The signalling plane is low-volume, must be reliable and ordered, and runs over ordinary TCP and WebSockets. The media plane is enormous, must be fast, and tolerates loss. Splitting them lets each use the right transport and scale separately — the same split as a network router's control and forwarding planes, or the stock broker's order path and price path earlier in R18.

Takeaway: Signalling decides, media flows; never send media through the signalling path.

Deep dive — connecting: signalling, ICE, STUN, TURN and DTLS-SRTP

Interviewer: How does Alice's laptop actually get packets to the media server? And why UDP?

You: Start with why UDP. TCP guarantees every byte arrives, in order. If one packet is lost, TCP holds back everything after it until the retransmission arrives — head-of-line blocking — and it also halves its sending rate. On a call, one lost packet would freeze audio and video for a full round trip or more, and the data released afterwards would already be late. UDP just sends packets, with no ordering or retransmission, and lets the application decide what to do about loss. For media that is exactly right: skip the lost 20 ms of audio, conceal it, and carry on. Media travels as RTP packets (each with a sequence number and a timestamp), with RTCP alongside for feedback such as loss reports.

Now the connection. WebRTC deliberately does not define signalling; the application provides it. Ours is the WebSocket.

Joining a meeting: signalling over the WebSocket, then ICE and DTLS directly with the SFU
sequenceDiagram
    participant A as Alice app
    participant S as Signalling and controller
    participant T as STUN server
    participant F as SFU
    A->>S: join meeting, token
    S-->>A: joined, SFU address, epoch 7
    A->>T: binding request
    T-->>A: your public address
    A->>S: SDP offer
    S->>F: SDP offer
    F-->>S: SDP answer
    S-->>A: SDP answer
    A->>F: ICE connectivity checks
    A->>F: DTLS handshake
    A->>F: SRTP media
    F->>A: SRTP media

Two things make our case easier than calls between two browsers. Our SFUs have public addresses, so most clients connect with their host or server-reflexive candidate and no relay; servers usually run a simplified "ICE-lite" mode. And because every client connects to the SFU rather than to each other, there is one connection per client, whatever the meeting size.

DTLS-SRTP encrypts each hop, not end to end: the SFU decrypts every packet and re-encrypts it for each receiver. So the operator's server could in principle access the media. That is the default in WebRTC conferencing, and the end-to-end option is a later deep dive.

What's being probed: whether you know what each WebRTC piece does, rather than reciting the acronyms.

Concept — NAT traversal. Most devices sit behind a router that shares one public address among many private ones (NAT). A server cannot simply "call" a laptop at home. ICE solves this by collecting every address a client might be reachable at and testing them all; STUN reveals the public one; TURN is the always-works relay when nothing else does. Think of trying the front door, then the side door, then calling a friend to pass messages through the window.

Takeaway: Signalling exchanges descriptions; ICE finds a path; DTLS agrees keys; SRTP carries media over UDP.

Deep dive — mesh, MCU or SFU, and simulcast

Interviewer: Why a media server at all? Browsers can connect to each other directly.

You: For two people they should. Beyond that, compare three topologies for an N-person call where each camera produces a stream of bitrate B. Take N = 10 and B = 1.2 Mbps.

The arithmetic:

N = 10, B = 1.2 Mbps

Mesh
  upload per client   = (N − 1) × B = 9 × 1.2   = 10.8 Mbps   ← home uplinks are often 5–20 Mbps
  encodes per client  = up to 9, one per receiver's quality; or 1, forcing one quality on all
  server              = none

MCU
  upload per client   = 1.2 Mbps; download = 1 composite ≈ 1.5 Mbps
  server              = decode 10 streams + encode a composite per layout, every frame
  extra delay         = decode + compose + encode ≈ tens of ms or more

SFU (with three layers per camera)
  upload per client   ≈ 1.8 Mbps (720p + 360p + 180p)
  download per client = 1 × 1.2 + 8 × 0.12 (gallery thumbnails) ≈ 2.2 Mbps
  server egress       = N × 2.2 ≈ 22 Mbps; streams forwarded = N × (N − 1) = 90
  server CPU          = per-packet encryption only; no decoding

Mesh dies on upload around four or five people. An MCU's delay and per-meeting CPU are expensive at 2 million meetings, though it is useful where a client can take only one stream, such as a phone line. The SFU wins: clients upload once, the server only copies packets, and each receiver can be given different quality. An SFU turns a video call into packet routing: it never decodes, so one server forwards hundreds of meetings, and each receiver gets the quality its own network can carry.

The SFU's cost grows with N × (N − 1) forwarded streams, which is fine at 5 and painful at 1,000; the large-meeting deep dive deals with that.

Interviewer: How does the SFU give Bob, on bad Wi-Fi, a different quality from Alice on fibre — without decoding?

You: The sender encodes several versions, and the SFU picks one.

To choose, the SFU needs to know each receiver's bandwidth. That is bandwidth estimation. The receiver reports when each packet arrived (WebRTC's transport-wide congestion control feedback); the sender — here the SFU — watches whether delays are growing, which signals a filling queue somewhere on the path, and whether packets are being lost, and adjusts its estimate. A growing delay is detected before loss starts, so the estimate drops early.

Each forwarded stream then follows a simple loop:

every ~200 ms, for each receiver:
    budget = bandwidth estimate − audio − screen share
    for each video the receiver wants, most important first (active speaker, then pinned, then thumbnails):
        pick the highest layer whose bitrate fits in the remaining budget,
        but never above the size the receiver will display it at
    if the chosen layer changed:
        switch at the next keyframe of the new layer (ask the sender for one if needed)

Two details make switching work. A receiver can only start decoding a different simulcast layer at a keyframe — a frame that does not depend on earlier frames — so the SFU waits for one, or requests one from the sender. And the SFU rewrites sequence numbers and timestamps so that, to Bob's decoder, the switch looks like one continuous stream.

The sender adapts too: the SFU estimates Alice's uplink, and if it cannot carry all three layers, her browser stops sending the top one. And because nobody needs a 720p version of a thumbnail, the SFU tells senders when no one is watching a layer, so they stop encoding it — layer pausing, which saves upload and battery.

What's being probed: the topology trade-off with numbers, and how adaptation works without transcoding.

Concept — let the edge choose. An MCU adapts by doing the work centrally; an SFU adapts by having senders prepare a few versions and choosing between them per receiver. It is the same idea as Netflix's bitrate ladder in R20, but decided by the server every few hundred milliseconds instead of by the player for each segment. The price is a little extra upload from every sender.

Takeaway: Mesh for two, SFU with simulcast or SVC for everything else, MCU only where a client can take just one stream.

Deep dive — loss and jitter: when late is worthless

Interviewer: Bob's Wi-Fi loses 5% of packets and they arrive unevenly. What happens?

You: Two problems: packets arrive at uneven intervals (jitter), and some never arrive (loss).

Jitter buffer. Bob's app does not play packets the moment they arrive. It holds them briefly in a jitter buffer, reorders them by sequence number, and plays them out at a steady pace. The buffer adapts: on a smooth network it shrinks to 20–40 ms; on a jittery one it grows, perhaps to 100–200 ms. Every millisecond it holds is a millisecond of delay, so it is the main dial between smoothness and delay. A typical one-way budget within a region:

capture + encode           20–40 ms
network, client → SFU      10–40 ms
SFU forwarding              1–5  ms
network, SFU → client      10–40 ms
jitter buffer              20–80 ms
decode + render            10–20 ms
total                     ~70–225 ms   → keep the typical case under ~150 ms

Loss. Each packet has a playout deadline — the moment the jitter buffer needs it. Every tool for loss is judged against that deadline:

Keyframes are several times larger than normal frames: a keyframe storm, after a failover or a big join, can itself cause the congestion that made you ask for it. SFUs rate-limit keyframe requests per sender.

So strictly, WebRTC does sometimes ask for a packet again — but only while the answer can still arrive in time. After the deadline the packet is abandoned, never queued. That is the rule from the start of this walkthrough, put into mechanism. Audio is always protected first: Bob can lose his video entirely and still hold the conversation, but not the other way round.

What's being probed: whether you know the loss-recovery toolbox and when each tool is worth using.

Concept — every packet has a deadline. Retransmission is useful only when a round trip is shorter than the time left before playout. FEC spends bandwidth to avoid the round trip; concealment hides what cannot be recovered; a keyframe resets a picture that broke. This is the opposite of R9's at-least-once delivery, where a message is retried until it lands: here a retry that misses its deadline is waste. R14's back-pressure choice for media is always "drop".

Takeaway: Buffer a little, repair what can arrive in time, conceal the rest, and reset video with a keyframe.

Deep dive — one owner per meeting: placement, cascading and failover

Interviewer: How do you make sure everyone in a meeting lands on the same SFU? And what happens when it dies?

You: This is R18's second problem — exactly one owner — applied to a meeting. If half the participants were sent to sfu-bom-1 and half to sfu-bom-2, each half would see only itself: a split-brain meeting. So the assignment meeting → SFU lives in the registry as a lease with an epoch (R8):

The controller itself is the owner of the meeting's control state, also under a lease, hashed by meeting id. Two owners, nested: one for who is in the meeting, one for where its media flows.

Placement. Delay is decided by geography, so SFUs sit in many regions. A meeting is placed near its first joiner, or its expected participants for a scheduled meeting. If participants are spread — Bengaluru and Frankfurt in one call — one SFU means someone has a long path. So we cascade: each region's participants connect to a local SFU, and the SFUs exchange streams over the provider's backbone. The registry then holds one SFU per meeting per region, each with its own lease, and the Mumbai SFU sends Frankfurt one copy of each layer anyone there needs, however many people in Frankfurt watch it. The intercontinental delay is still there, but each person's weak last mile is short, and loss on it is repaired locally.

Failover. Now the SFU dies.

  1. Detection. WebRTC's own consent checks take up to 30 seconds to give up — far too slow. Instead the controller sees the missed heartbeats, and clients see no packets for about 2 seconds.
  2. The lease lapses after a few seconds. The allocator assigns the meeting to sfu-bom-2 with epoch 8.
  3. The controller tells every participant over signalling: "move to sfu-bom-2, epoch 8". Clients open a new connection to it — new ICE checks and a DTLS handshake — and republish their tracks, with jittered start times so 700 meetings' worth of clients do not arrive in the same instant (R18's fourth problem).
  4. The new SFU asks every sender for a keyframe, rate-limited, and pictures return.

What was lost, and what is rebuilt:

State After an SFU failure
Packets in flight, and the NACK history Lost, and it does not matter: they were already late
Which layer each receiver gets, bandwidth estimates Rebuilt in a second or two from fresh feedback
Stream ids and encryption keys Re-created by the new DTLS handshake and SDP
Roster, roles, mutes, who is sharing Not lost: held by the controller, not the SFU
Chat Not lost: separate durable service
Recording Loses a few seconds: the recorder reconnects and starts a new segment

The SFU holds only state that can be rebuilt from the clients in seconds; anything that must survive lives with the controller or a durable store. That is WhatsApp's rule — the server holds connections, the truth lives elsewhere — applied to media.

A smaller version is far more common: Alice walks from Wi-Fi to mobile data. Her address changes, but the SFU is fine. She does an ICE restart — new ICE credentials, new candidates, new checks — on the same session, and media resumes in about a second without rejoining.

What's being probed: single ownership with fencing, and a precise account of what is lost on failover.

Concept — disposable state versus owned state. An SFU looks stateful — it knows thousands of streams — but almost all of that state is soft: the clients can recreate it by reconnecting. The lease and epoch exist so that during the switch there are never two live owners of one meeting; the controller exists so that the hard state, the roster, is somewhere else. R8 covers leases and fencing; the R18 introduction lists "keep the truth outside the server" as the first durability move.

Takeaway: One leased, epoch-fenced SFU per meeting per region; on failure, reassign, reconnect with jitter and rebuild.

Deep dive — large meetings, webinars, side paths and encryption

Interviewer: Now a 1,000-person all-hands, and a 50,000-viewer webinar.

You: Forwarding everyone to everyone breaks first.

1,000-person meeting, every video to every receiver at 180p:
    per receiver  = 999 × 0.12 Mbps ≈ 120 Mbps     ← impossible on a phone
    SFU egress    = 1,000 × 120 Mbps = 120 Gbps    ← impossible on a server

With last-N = 25 visible tiles at 180p, plus the active speaker at 720p:
    per receiver  = 25 × 0.12 + 1.2 ≈ 4.2 Mbps
    meeting egress = 1,000 × 4.2 Mbps ≈ 4.2 Gbps → spread over several cascaded SFUs

A webinar is the extreme: 10 panelists send, 50,000 attendees only receive.

Viewer download ≈ 1 speaker video at 720p + 3 audio ≈ 1.2 + 0.12 ≈ 1.3 Mbps
Total egress    = 50,000 × 1.3 Mbps ≈ 65 Gbps → at 6 Gbps planned per SFU ≈ 11 edge SFUs
Origin → edges  = 10 panelists × 1.8 Mbps × 11 edges ≈ 200 Mbps

So it becomes a tree: panelists connect to an origin SFU, which feeds edge SFUs in each region, which feed the attendees. Attendees send no media, so their joins only add receivers. Promoting an attendee is a signalling change: they start publishing to their edge SFU, which forwards up the tree. Beyond a few hundred thousand viewers, or if a few seconds of delay is acceptable, the better tool is to encode one composite stream and serve it as HTTP segments through a CDN, as Netflix does in R20 — interactivity traded for reach.

Screen share is another video track with different rules. Text must stay sharp, so the encoder is told the content is detail ("content hint"), keeps the resolution high and drops the frame rate to 5–15 frames a second. Its keyframes are large, because a whole screen of text compresses poorly. When bandwidth runs short, the SFU protects, in order: audio, then screen share, then the active speaker's video, then thumbnails.

Chat is not media: every message matters. It goes over the signalling socket to a chat service that stores it durably with a per-meeting sequence number before acknowledging, and a client that reconnects asks for "everything after sequence 412". It is WhatsApp inside a meeting, and it uses none of the media machinery.

Recording is another receiver. A recorder joins as a hidden participant, subscribes to every stream at the top layer, and writes the packets per track to object storage in segments of a few seconds, so a recorder crash loses seconds, not the meeting. After the meeting a batch job decodes and composites the tracks into one video. Live transcription works the same way.

Interviewer: Customers want end-to-end encryption.

You: With DTLS-SRTP alone, the SFU sees plaintext media. For end-to-end encryption, each client encrypts every encoded frame with a key the server never has, before the browser packetises it — WebRTC's insertable streams (the encoded transform API) let the app do exactly that, and SFrame is a standard format for such frame encryption. DTLS-SRTP still protects each hop on top.

The SFU can still do its job, because it never needed the picture: it routes on RTP headers and header extensions — sequence numbers, the audio level, the layer descriptor for SVC — which stay readable. That is why SVC layer information must sit outside the encrypted payload. What the server loses:

So end-to-end encryption is an option per meeting, with the host told which features turn off.

Failures and scale

Interviewer: What breaks, and how does it recover?

You:

Failure or pressure What happens Mitigation
Top-of-hour join spike ~17,000 joins/s for a few minutes Signalling scaled for it; allocator pre-warms SFU capacity by schedule; jittered client retries
SFU dies Its meetings lose media Lease lapses; reassign with a new epoch; clients reconnect with jitter; rate-limited keyframes
Zombie SFU wakes after losing its lease Could try to serve a meeting that moved Tokens and controller messages carry the epoch; old epoch refused
Meeting controller dies No joins or mutes for that meeting; media keeps flowing Standby takes the lease; roster rebuilt from the journal and from SFU reports
Participant's network degrades Loss and jitter Bandwidth estimate drops; lower layer; NACK and FEC; audio protected first
Participant changes network Address changes ICE restart on the same session
UDP blocked No direct path TURN over TLS on 443, or TCP to the SFU; lower quality, still connected
A region's SFUs are full New meetings cannot be placed nearby Place in the next-nearest region; shed the lowest layers; add capacity ahead of known peaks
Hot meeting outgrows one SFU Egress limit reached Cascade to more SFUs; last-N and top-N audio
Region loss Every SFU there is gone Controllers and allocators elsewhere reassign; clients reconnect to the next region with higher delay
Chat or recording store slow Chat delayed; recording segments queue on the recorder Separate paths: media never waits on them

Curveballs

Interviewer: Why not run media over WebSockets? It would be simpler.

You: WebSockets run over TCP, so one lost packet stalls everything behind it, and TCP's congestion control backs off hard. For a stream with a 150 ms budget that means freezes. It is fine for chat and signalling, which is exactly what it is used for here. Newer transports such as WebTransport over QUIC allow unreliable datagrams and may change this, but the principle — no head-of-line blocking for media — stays.

Interviewer: Two hosts both press "end meeting" while the SFU is failing over.

You: Ending is a command to the controller, which owns the meeting state and processes commands in order; the second is a no-op. The controller then tells whichever SFU holds the current epoch to drop the meeting. The SFU failover does not touch this, because the SFU never owned the meeting's life cycle.

Interviewer: A participant's video is frozen but the audio is fine.

You: Likely their uplink cannot carry video: bandwidth estimation dropped them to the lowest layer or paused video, and audio is protected first. Or a keyframe request is being rate-limited after loss. Our quality metrics — per-stream loss, jitter, round-trip time, layer switches, freeze counts — are reported by clients and SFUs, so support can see which hop failed (R15).

Interviewer: Could we put the SFU on the participants' own machines to save money?

You: For two people we already do: a one-to-one call can go peer to peer and skip the server entirely, falling back to TURN or the SFU when the direct path fails. Beyond that, a participant's home uplink becomes the meeting's bottleneck and its single point of failure, which is the mesh problem again.

Quick reference

Decision Choice Why
Transport for media RTP over UDP, encrypted with DTLS-SRTP No head-of-line blocking; the app decides what to do about loss
Signalling WebSocket to gateways, routed to a meeting controller Reliable, ordered control; WhatsApp-sized
Connectivity ICE with STUN; TURN over TLS 443 as fallback Works through home routers and strict firewalls
Topology SFU; peer to peer for one-to-one; MCU only for single-stream clients Upload once, forward without decoding
Adaptation Simulcast or SVC, chosen per receiver from bandwidth estimates Each receiver gets what its network carries
Loss Jitter buffer, NACK within deadline, FEC, concealment, PLI/FIR Repair in time or skip; never queue late media
Ownership One SFU per meeting per region, lease plus epoch No split-brain meetings; zombies fenced
Placement Near participants; cascade across regions Short last mile; one copy per region over the backbone
Large meetings Active speaker, last-N video, top-N audio; webinar tree Egress grows with what is seen, not with N²
Priority under pressure Audio > screen share > speaker > thumbnails People can talk without video, not without audio
Side paths Chat in a durable log; recording as a hidden participant Every message matters; media frames do not
Failover Reassign, reconnect with jitter, rebuild soft state SFU holds nothing that cannot be rebuilt
End-to-end encryption Frame encryption via insertable streams, SFrame-style; optional Server routes on headers; loses recording and mixing

The 60-second version

A video call has two planes. Signalling — WebSockets to gateways, routed to a meeting controller that owns each meeting's roster — exchanges SDP offers and answers and tells every client which media server to use. Media flows as RTP over UDP, because a late frame is worthless and TCP would stall on every loss; ICE finds a path using STUN, TURN relays over TLS when UDP is blocked, and DTLS-SRTP encrypts each hop. Mesh fails on upload beyond a few people and an MCU spends too much CPU, so each meeting gets one SFU that receives each stream once and forwards, without decoding, the simulcast or SVC layer each receiver's bandwidth estimate allows. Receivers smooth jitter with a buffer, repair loss with NACK and FEC only while a packet can still arrive in time, and reset broken video with keyframe requests. The meeting-to-SFU assignment is a lease with an epoch, so there is never a split-brain meeting; meetings are placed near participants and cascaded across regions, and large meetings send only what is seen — active speaker, last-N video, top-N audio — with webinars as a tree of SFUs. When an SFU dies, the lease lapses, clients reconnect with jitter to a new one and rebuild soft state in seconds; the roster, chat and recordings were never on the SFU. End-to-end encryption encrypts frames above the transport, which the SFU can still route, at the cost of server-side recording and mixing.


What to say in the interview

"These systems keep state in a server because it cannot live anywhere else — a socket is tied to one machine, a document being edited needs one ordering point, and a risk check that takes microseconds cannot wait for a database. So I answer four questions out loud. How does a request find the owner? A registry for things that move, like phones; hashing for things that stay put, like documents and accounts. How do I guarantee there is only one owner? Leases and epochs, with every write fenced. Where is the durable truth? An inbox, an op log or a replicated journal — always written before I acknowledge. And how do clients recover? Jittered reconnects, snapshot then delta. Persist before acknowledge; the inbox, not the registry, is the source of truth; one fenced owner per document orders every op; and when the logic is deterministic, a replica that replays the same log is a hot standby for free. For live media the rule flips: nothing late is worth retrying, so one SFU per meeting forwards packets over UDP, each receiver gets the layer its network can carry, and a failed server is replaced by reconnecting, not by replay."