SD-09 · Chat / messaging — 1:1 and groups
A reference solution, stage by stage, for a 45–60-minute round. Cue sheet:
questions/B-social-feed-content.md · theory:
Real-time & connections,
Messaging & streams,
Correctness under failure.
Run the round cold first:
Mock me: SD-09, Meta, em, full round.Read this afterwards.
The prompt: “Design a messaging system — one-to-one and group chats, for a billion users, with delivery and read receipts and support for clients that go offline.”
The one-sentence insight to land by minute 12
Start from the mailbox, not the socket. A message is durably stored in its conversation log with a server-assigned sequence number before anything is acknowledged; every device then catches up by cursor (“everything after #1,234”). Pushing over a WebSocket is just an optimisation that delivers early — so online and offline become the same code path, and nothing is lost or duplicated.
Stage 1 · Clarify requirements · 0:00–0:06
| I ask | Interviewer answers | Why it matters |
|---|---|---|
| Scale? | 1 B users, 100 M concurrently connected | the connection tier is a major cost |
| Message rate? | 1 M msg/s peak | log and storage sizing |
| Group size? | up to 1,000 | fan-out model; receipt traffic |
| Guarantees? | never lose, never show twice, consistent order for all participants | server sequencing, idempotent send, store-before-deliver |
| Receipts? | sent, delivered, read | a second message flow, potentially larger than the first |
| History? | full, indefinite, searchable by the user | storage tier and a search index |
| Devices? | up to 5 per user, all converge | per-device cursors |
| Encryption? | assume not end-to-end; say what it would change | server-side search vs E2E is a real trade-off |
| Media? | out of scope | |
| Worse: losing a message or showing a duplicate? | (ask) | both are banned — the design must satisfy both, so: durable write + idempotency |
Out of scope (say it): media, voice/video calls, E2E encryption (discussed as a delta), spam detection, the push-notification platform itself (SD-12).
Non-functional: message delivered to online recipients p99 < 500 ms; zero loss after the sender’s ✓ (sent); per-conversation total order; history durable for years; availability 99.99% for send.
Stage 2 · Back-of-the-envelope · 0:06–0:11
| Quantity | Arithmetic | Result | Consequence |
|---|---|---|---|
| Connections | 100 M × ~20 KB | ~2 TB RAM; at ~500 k per host ~200–300 gateway hosts + zone headroom | a dedicated, stateless-as-possible gateway tier |
| Storage | 1 M msg/s × ~200 B (text + metadata) × 10⁵ s | ~20 TB/day raw, ~60 TB/day replicated, ~20 PB/year | wide-column store partitioned by conversation; tiering of old history |
| Group fan-out | 1 msg to a 1,000-member group | 1 log write vs 1,000 inbox writes | shared conversation log + per-member cursor for groups |
| Read receipts | 1,000 members each reading → 1,000 receipts per message, each to up to 999 others | ~10⁶ receipt deliveries per group message | aggregate receipts (“read by 412”) — never fan out per-reader receipts in big groups |
| Heartbeats | 100 M ÷ 30 s | ~3.3 M/s | handled at the gateway, never in the message path |
“Two numbers matter. Storage: about twenty terabytes a day, so history goes into a partitioned wide-column store keyed by conversation. And receipts: in a thousand-person group, read receipts are a million deliveries per message, so in large groups I aggregate them instead of fanning them out.”
Stage 3 · API and data model · 0:11–0:17
Client ↔ gateway (WebSocket frames):
→ SEND { convId, clientMsgId, body } ← ACK { clientMsgId, seq, serverTs }
← MSG { convId, seq, senderId, body, serverTs } → DELIVERED { convId, upToSeq }
→ READ { convId, upToSeq }
→ SYNC { cursors: { convId: lastSeq, … } } ← MSG… (missed messages), then LIVE
REST (history, setup): GET /v1/conversations/{id}/messages?beforeSeq=… ·
POST /v1/conversations · POST /v1/conversations/{id}/members.
| Store | Key | Content |
|---|---|---|
| Messages | partition conv_id, clustering seq |
sender, client_msg_id, body, server_ts, edit/delete flags |
| Conversation sequencer | conv_id |
last_seq (owned by one partition leader) |
| Membership | conv_id → members; user_id → conversations |
roles, join seq |
| Per-device cursors | (user_id, device_id, conv_id) |
delivered_seq, read_seq |
| User inbox index | user_id |
conversations with activity since a time — “what changed” for sync |
| Session registry | (user_id, device_id) |
gateway node, TTL |
| Search index | per user | message text (async from the log) |
Stage 4 · High-level design · 0:17–0:27
flowchart LR
A[Sender device] -->|WebSocket| GW1[Gateway]
GW1 --> MS[Message service]
MS -->|append with next seq<br/>idempotent on clientMsgId| MDB[(Messages<br/>partitioned by conv_id)]
MDB -. CDC / outbox .-> LOG[[Message log<br/>by conv_id]]
LOG --> DLV[Delivery workers]
DLV -->|members' devices| REG[(Session registry)]
DLV -->|online| GW2[Gateway] -->|MSG| B[Recipient devices]
DLV -->|offline| PUSH[Push notifications<br/>SD-12]
LOG --> IDX[Inbox index + search]
B -->|DELIVERED / READ| GW2 --> RCPT[Receipt service] --> CUR[(Device cursors)]
Walk-through — Alice sends to Bob, who is offline:
- Alice’s app sends
SEND {convId, clientMsgId, body}over its socket. - The message service routes to the conversation’s partition leader, which assigns
seq = last_seq + 1and writes the message — durably, replicated — keyed by(convId, seq), with a uniqueness check onclientMsgId. - Only now does Alice get
ACK {seq}→ ✓ sent. - The delivery worker (from the log) finds Bob’s devices in the registry: none online → sends a push notification (“New message from Alice”) and updates Bob’s inbox index.
- Bob opens the app later; it sends
SYNC {cursors}; the server streams every message after his cursor for each changed conversation; the app stores them and sendsDELIVERED {upToSeq}→ Alice sees ✓✓.
Same message, Bob online on two devices: step 4 finds two gateway entries and pushes MSG to
both; each device acks independently; each device’s cursor advances separately.
Stage 5 · Technology choices
| Component | Choice | Reason |
|---|---|---|
| Gateways | WebSocket servers holding only connection state | connection count is the scaling dimension; keep logic out |
| Messages | wide-column store (Cassandra/Bigtable-class), partition conv_id, cluster seq |
append-heavy, read by conversation in order, enormous volume |
| Sequencer | per-conversation leader (e.g. the partition’s Raft leader, or a sequencer service sharded by conv_id) |
one authority per conversation = one consistent order |
| Log | partitioned by conv_id |
ordered per conversation, replayable for delivery, search and push |
| Registry | in-memory KV with TTLs, refreshed by heartbeats | hot, small, tolerates staleness |
| Search | per-user inverted index, async | not on the send path |
Stage 6 · Deep dives · 0:27–0:45
6.1 Ordering — who decides?
Two members send at the same instant. Client timestamps are useless (clocks skew, users change
them). The conversation’s single sequencer assigns seq in arrival order at the leader; that
order is the truth for every participant on every device. A sender’s own messages show locally as
pending until the ACK returns the seq, then slot into place.
Why one sequencer per conversation and not a global one? A global order would be a bottleneck and is meaningless across conversations. Per-conversation ordering is what users perceive, and it partitions perfectly — 1 M msg/s spread over hundreds of millions of conversations.
Hot conversation (a 1,000-member group in a frenzy): one leader handles it — thousands of messages per second per conversation is well within one partition leader’s capacity.
6.2 Idempotent send and the lost ACK
The phone sends, the server stores, the ACK is lost (tunnel, crash). The app retries with the same
clientMsgId; the uniqueness check on (convId, clientMsgId) returns the original seq — no
duplicate. The sender sees ✓ only after a real ACK, so a message is never shown as sent when it
wasn’t.
6.3 Groups: fan-out on write vs shared log
| Per-member inbox copies | Shared conversation log + cursors | |
|---|---|---|
| Write per message | 1,000 writes | 1 write |
| Read | read own inbox | read conversation after cursor |
| Edits/deletes | update 1,000 copies | update one row |
| Storage | ×group size | ×1 |
Chosen: shared log. The per-user inbox index only records which conversations changed, so sync is “list changed conversations, then read each after my cursor”. Small 1:1 conversations use the same model — one code path.
6.4 Receipts
- Delivered/read = per-device cursors (
upToSeq), so one receipt covers many messages. - 1:1 and small groups: forward cursor updates to the other participants’ devices.
- Large groups: store cursors; aggregate on demand (“read by 412 of 1,000”) when a user opens message info — never broadcast per-reader receipts.
- Receipts travel on a separate, lower-priority path so they can’t delay messages.
6.5 A gateway dies with 500 k connections
All 500 k reconnect at once → TLS handshakes, auth, SYNC storms. Mitigations: client jittered
backoff, TLS session resumption, cursor-based sync so catch-up is “deltas since seq N” (cheap),
admission control on the sync service, and graceful drain on deploys (reconnect gradually over
minutes). Messages are safe throughout — they’re in the log, not in the dead gateway.
6.6 Coming back after a month offline
SYNC with stale cursors → the inbox index lists changed conversations since the last sync → the
server streams the latest page of each (newest first, so the UI is useful quickly) and lazily
backfills older messages as the user scrolls. Cap the burst per device.
Stage 7 · Mid-round requirement — edit and delete for everyone
Because the conversation log is the single source of truth and devices hold caches, edit/delete is
an event appended to the conversation (EDIT seq=1,234, DELETE seq=1,240) that devices apply on
sync, plus an update of the stored row. Cost: devices must handle the events (old clients that don’t
understand them show the original — a schema-evolution concern: version the protocol and keep
unknown event types harmless). If we had fanned out copies into 1,000 inboxes, this would be 1,000
writes per edit.
If E2E encryption were required: the server can’t read bodies → server-side search disappears (search becomes on-device), and group fan-out encrypts per member device (sender-key schemes reduce the cost). Say this as a trade-off, not a footnote.
Stage 8 · Failure modes · 0:45–0:50
| Failure | Effect | Response |
|---|---|---|
| Gateway node loss | 500 k reconnects | jitter, resumption, cursor sync, admission control |
| Registry stale | delivery to a dead gateway fails | message remains in the log; device syncs on reconnect |
| Partition leader failover | sends to that conversation pause ~seconds | consensus failover; clients retry with same clientMsgId |
| Log consumer lag | delayed delivery to online users | alert on lag; delivery has priority over search/indexing consumers |
| Region outage | users in region disconnected | conversations homed per region with async replication; failover with possible seconds of reordering risk — state it |
Stage 9 · SLOs, observability, cost · 0:50–0:54
- SLIs: send success (ACK received) · end-to-end delivery latency to online recipients measured by synthetic conversations between probe devices · sync completion time after reconnect · duplicate/loss rate from probe conversations (should be zero).
- Cost: the gateway tier (RAM for 100 M connections) and storage growth (~20 PB/year) — tier old history to cheaper storage, compress, and keep only recent months on fast disks.
Stage 10 · The level layer
Staff: go deep on the sequencer (leader per conversation, failover without reordering), the sync protocol (cursor semantics, gap detection), and protocol versioning for slow-updating clients. EM: MVP = store-then-deliver with cursors for 1:1, then groups on the same log, then receipts aggregation; team seams: connection tier · messaging core & storage · sync & clients · search; on-call on delivery-latency SLO; capacity plan for the gateway fleet. Director: the E2E-encryption decision (privacy vs search and moderation) is a product and legal call — frame the options and costs.
Stage 11 · The close
“Messages are durably appended to a per-conversation log with a server-assigned sequence number before the sender gets an ack, and every device catches up by cursor — the WebSocket push only delivers early. That gives one consistent order, no loss and no duplicates via client message IDs, and makes offline and multi-device the same path. Groups share one log instead of copying into inboxes, and receipts are cursors, aggregated in large groups. Next I’d harden reconnect storms and tier the twenty petabytes a year of history.”
Stage 12 · Self-check
- Store before deliver, stated explicitly?
- Server-assigned per-conversation sequence, not timestamps?
- Idempotent send with
clientMsgIdand the lost-ACK case? - Per-device cursors and the offline catch-up walk?
- Group model chosen with numbers; receipt traffic trap found?
- Reconnect storm and its mitigations?
Cheat card
Insight: mailbox (durable log + cursor) first, socket second. Numbers: 100 M connections ≈
2 TB RAM, ~250 gateways; ~20 TB/day of messages; receipts ×1,000 in big groups. Order: one
sequencer per conversation. Dedup: clientMsgId. Groups: shared log, inbox index of changed
conversations. Receipts: cursors, aggregated. Trap: deliver-then-store; client timestamps;
groups as N 1:1 chats.