Design a Chat System
1. The problem
1:1 and group messaging (WhatsApp, Messenger, Slack, Discord). Three properties make it different from everything else in this notes set:
- The server must start the conversation. When a friend messages you, the server pushes to you unasked — inverts HTTP's request/response model.
- Connections make servers stateful. To push, a server holds your open connection; that server "owns" you while connected → scaling, deploys and recovery all change.
- Correctness is per conversation. No message lost, none duplicated, right order, on every device you own.
Chat is a delivery-and-sync system, not a "send over WebSocket" system.
| System | Shape |
|---|---|
| Notification | one-way pipeline, fire and forget |
| News feed | read-heavy (~100:1), precompute |
| Chat | ~1:1 read:write, real-time, bidirectional, per-conversation ordered, multi-device → deliver, don't precompute |
2. Requirements (extract these)
Book's scoped answers:
| Question | Answer |
|---|---|
| 1:1 or group? | both; group cap 100 |
| Clients | mobile + web |
| Scale | 50M DAU |
| Features | text only (< 100,000 chars), online presence, multi-device, push notifications |
| History | stored forever |
| E2EE | not required (for now) |
Senior questions to ask unprompted — each changes the design:
- E2EE or not? The biggest fork. Server holds only ciphertext → no server-side search, history sync or moderation (§13).
- Max group size? 100 (WhatsApp) vs 100,000 (Discord/Slack) → different fan-out (§11) — the news-feed push/pull question in real time.
- Guarantees? State the stance: at-least-once + dedup, ordering per conversation, eventual consistency for presence/typing.
- Read receipts in groups? Per-message-per-user status explodes → cursors (§7).
3. Estimation
Messages: 50M DAU × 40/day = 2×10⁹/day ÷ 10⁵ ≈ 20,000 msgs/sec avg, peak ×5 ≈ 100,000/sec
Storage: 2×10⁹ × ~200 B (text + metadata) ≈ 400 GB/day ≈ 150 TB/yr raw, ×3 replicas ≈ 450 TB/yr
Connections: 50M DAU × ~20% online = 10M concurrent WebSockets
Memory: 10M × ~10 KB/connection = 100 GB total
Fleet: ~100K connections/box (planning) ≈ 100 chat servers + failover headroom- 20k–100k writes/sec growing forever → no single RDBMS; append-optimised store (§8).
- 10M connections sizes the fleet. ⚠️ Connections scale with concurrent users, not messages — a silent online user still costs a socket, heartbeats, memory.
- Read:write ≈ 1:1 for 1:1 chat (each message read ~once) → deliver, don't precompute.
- WhatsApp reported 2M+ connections on one server — a record, not a planning number.
4. The protocol problem: how does the server push?
Sender side is easy (HTTP works). Receiver side needs server-initiated traffic.
| Technique | How | Why it breaks |
|---|---|---|
| Polling | "anything new?" every few seconds | mostly "no" → wasted server work + battery; latency up to one interval |
| Long polling | server holds the request until a message or timeout, client re-asks | sender and receiver may sit on different servers behind a stateless LB; server can't tell a client disconnected; idle users reconnect constantly |
| WebSocket ✅ | HTTP connection upgraded to persistent, bidirectional | wins: either side sends anytime, ports 80/443 pass firewalls. Cost: stateful servers |
SSE (Server-Sent Events) = one-way server→client stream; fine for notifications, chat needs two-way → WebSocket for both send and receive.
5. High-level design
Split by how each part scales:
- Stateless (HTTP behind LB): login, signup, profile, groups, history fetch, service discovery. Add boxes freely.
- Stateful (WebSocket): chat servers (send/receive), presence servers (who's online). A client sticks to one server for the life of the connection.
- Third-party: push notifications for offline recipients — the notification-system lecture (idempotency keys, collapse IDs, no content in payload).
- Storage: user DB (relational) · chat history (KV / wide-column) · presence + sessions (cache with TTL keys).
5.1 Service discovery — how a client gets its chat server
- Client logs in over HTTP → LB → API servers.
- API server authenticates.
- Service discovery (ZooKeeper in the book) picks the best chat server by region + current load.
- API returns
{token, ws_url}. - Client opens the WebSocket to that server and keeps it.
- Chat server records
user:device → server_idin the session registry (§6).
5.2 Operational cost of statefulness (raise unprompted)
- Reconnect storm: a chat server dies → ~100K clients reconnect in the same second → overloads discovery + survivors. Fix: client exponential backoff with jitter (same rule as notification retries).
- Deploys: never hard-restart. Drain: stop accepting new connections, ask existing clients to reconnect elsewhere over minutes.
6. Cross-server routing — how server A reaches user B on server B
Two levels of lookup + a carrier:
| Piece | Where it lives | Answers |
|---|---|---|
| Session registry | shared cache (Redis) | which server holds B's device? |
| Carrier | RPC or a queue | how the bytes get to that server |
| Local socket map | inside server B's process memory | which socket object is B's |
A socket is a file descriptor inside one process — it can't live in Redis. That's why there are two levels.
# 0. server B, on connect
local_sockets["B:phone"] = <socket> # process memory
HSET session:B phone server-B ; EXPIRE session:B 120 # shared registry
# 1. server A: persist, ack A (one tick)
# 2. server A: HGETALL session:B → {phone: server-B} (~0.5 ms)
# empty → B offline → push notification, stop; B syncs on reconnect
# several devices → forward to each
# 3. server A → server B: deliver(B, msg)
# 4. server B: local_sockets["B:phone"].send(msg) → wait for delivered-ackCarrier choices
| Pattern | How A finds B | Best for | Weak point |
|---|---|---|---|
| Registry + direct RPC (gRPC between chat servers) | lookup, then call server B | 1:1 — the default answer | registry can be stale |
Registry + per-server queue (Kafka / NATS / Redis Streams inbox.server-B) | lookup, publish to server B's inbox | 1:1 with buffering if server B is slow | a few ms more latency |
Pub/sub per user — server B SUBSCRIBE user:B, server A PUBLISH user:B | doesn't look up; the subscription is the registry | simple systems; multi-device for free | fire-and-forget (no subscriber → dropped); classic Redis Cluster broadcasts every publish to every node → use sharded pub/sub (SSUBSCRIBE/SPUBLISH, Redis 7) |
| Consistent-hash ownership (Slack) | hash(channel_id) → owning channel server | large groups/channels | more moving parts (§11) |
When the registry is wrong
| Case | Outcome |
|---|---|
| Server B crashed, registry still points at it | forward fails / no socket; message already stored → B reconnects to server C, syncs from cursor |
| B moved to server C while message in flight to B | dropped at B; included in B's reconnect sync |
| B receives it twice (live push + sync) | client dedups by message ID |
Keep staleness short:
- registry TTL refreshed every ~minute while connected — not on every 5 s heartbeat (10M users ÷ 5 s = 2M writes/s for nothing),
- better: each chat server holds an ephemeral node in service discovery; before forwarding, check server B is alive — entries pointing at a dead server are stale immediately.
Routing is the fast path, not the guarantee. Persist-before-ack + per-device cursor sync make a message late, never lost.
7. Sending a message — the 1:1 flow and acknowledgements
- A's client sends
{client_msg_id, conversation_id, body}—client_msg_idis a UUID made on the device before sending (dedup key). - Chat server 1 assigns order — server-side ID /
seq; the single ordering point for the conversation (§9). - Persist, then ack A. One tick = "server has it stored." If the server acked first and crashed before the write, A would believe a vanished message was safe.
- Queue for delivery — sender's server doesn't block on the recipient.
- Registry lookup — which servers hold B's devices (§6).
- Online → chat server 2 pushes over B's WebSocket. Offline → push notification (message is already stored; push is just a nudge).
- B reconnects later → syncs from cursor (§10).
The three ticks — three promises, three different confirmers
| State | Confirmed by | Meaning |
|---|---|---|
| Sent ✓ | the server | durably stored |
| Delivered ✓✓ | the recipient's device | it arrived (server alone only knows it wrote bytes to a socket that may be dead) |
| Read (blue) | the recipient's app | user opened the chat with it on screen |
Duplicates (ambiguous timeout again): ack to A lost → A retries the same
client_msg_id → UNIQUE(sender_id, client_msg_id) returns the existing
message instead of inserting a second. Receiver side: delivered-ack lost →
server redelivers → client drops any message ID it already has.
→ at-least-once + dedup at both ends.
Receipts are cursors, not per-message rows.
- Client sends one "read up to message X", debounced — not one event per message.
- Server stores
last_read_msg_idper (user, conversation) andlast_delivered_msg_idper (user, device, conversation). - Per-message-per-recipient status in a 100-member group = 100 writes per message → the classic pitfall.
Multi-device rule (pick and state one): delivered when any device has it; read when any device has read it.
8. Storage
8.1 Two kinds of data
| Data | Shape | Store |
|---|---|---|
| Generic — users, profiles, settings, friends, group membership | small, relational, read often | replicated + sharded RDBMS |
| Chat history | enormous, grows forever (~150 TB/yr raw); most reads are recent; still needs random access (search, jump to reply, mentions); 1:1 read:write ≈ 1:1 | KV / wide-column (Cassandra, ScyllaDB, HBase) — partition by key, sorted within partition, LSM sequential writes |
Facebook Messenger: HBase. Discord: Cassandra → ScyllaDB.
8.2 Schema — every chat is a conversation
A 1:1 chat is a conversation with two members → one table for both. Discord's real schema:
CREATE TABLE messages (
channel_id bigint,
bucket int, -- static time window
message_id bigint, -- Snowflake: sorted by time
author_id bigint,
content text,
PRIMARY KEY ((channel_id, bucket), message_id)
) WITH CLUSTERING ORDER BY (message_id DESC);- Open a chat = one partition read — latest 50 are contiguous, newest first.
- Bucket caps partition size — a 10-year busy chat isn't one giant partition; scrolling back moves to the previous bucket.
- Partitioning by conversation spreads load (consistent hashing).
8.3 Hot partitions (senior follow-up)
Busy channels concentrate traffic on their current bucket; one hot partition slowed Discord's whole cluster. Reusable fixes:
- Request coalescing — 1,000 identical in-flight reads → one DB query, result shared (same idea as the cache-stampede guard).
- Data-services layer routing by
channel_id→ identical requests land on one instance and can be coalesced there. - Cassandra → ScyllaDB (same model, C++, no GC): historical-read p99 40–125 ms → ~15 ms; insert p99 5–70 ms → ~5 ms.
9. Message IDs and ordering
Requirements: unique and sortable by time (newer = larger). Client timestamps are useless — device clocks disagree by seconds to minutes.
| Option | Strength | Weakness |
|---|---|---|
| Global Snowflake (unique-ID notes) | no coordination, time-sortable | only k-sortable across servers (clock skew) → same-conversation messages via two servers within ms can display out of order |
Per-conversation sequence (Redis INCR / DB sequence) | strict, gap-free order within the conversation | needs one ordering point per conversation |
- You only need order within a conversation, never across. So the "bottleneck" is millions of independent counters spread over the cluster — cheap. The book recommends this local sequence.
- Common combo: Snowflake as storage key + per-conversation
seqfor display order. - Gap detection (bonus of
seq): receive 41 then 43 → fetch 42. Snowflakes can't reveal a missing message.
10. Multi-device sync and offline delivery
Each device keeps its own cur_max_message_id (cursor) per conversation.
On connect/reconnect it asks for everything newer.
1. reconnect via service discovery (maybe a different chat server)
2. send cursors: conv 42 → seq 40, conv 99 → seq 1,203 …
3. server returns everything newer, in order
4. device dedups by message ID (some arrived live just before the drop) → delivered-acks
5. acks reach senders → their messages flip ✓ → ✓✓History + per-device cursors define correctness. The live push only makes it fast. A lost push is picked up by the next sync.
11. Group chat — the news-feed fan-out in real time
| Small group (book, ≤ 100) | Large channel (Slack, Discord) | |
|---|---|---|
| Model | copy into each member's message sync queue (per-user inbox) — fan-out on write | store once in the channel log, fan out to servers, not users |
| Cost | N writes per message | 1 write + one message per subscribed gateway |
| Client | checks only its own inbox → simple sync | gateway delivers to local members in memory |
| Example | WeChat (groups in the hundreds) | Slack channel/gateway servers |
Slack's model:
- Channel servers (stateful) each own a subset of channels via consistent hashing.
- Gateway servers (stateful, per region) hold WebSockets + each user's channel subscriptions.
- On connect, the gateway subscribes to the channel servers owning the user's channels.
- A message is routed by hashing the channel ID → owning channel server → pushed to every subscribed gateway → each gateway delivers locally.
- 100,000 members across 200 gateways = 200 server messages, not 100,000.
"Group chat is the news-feed fan-out decision in real time: small groups copy into inboxes, large channels store once and publish to the servers holding subscribers. Member count is the threshold, like follower count."
12. Online presence
| Event | Handling |
|---|---|
| Login | WebSocket up → status=online, last_active_at=now |
| Logout | client tells server → offline |
| Disconnection | heartbeat + timeout (below) — never flip on socket drop |
- Client heartbeats every 5 s; offline only after 30 s with none → tunnels and Wi-Fi→LTE switches don't flicker the dot.
- Implementation:
SET presence:B online EX 30, refreshed by each heartbeat. Expiry IS offline detection — no sweeper job. - Never write heartbeats to the durable DB (10M ÷ 5 s = 2M writes/s of disposable data).
- Fan-out: book uses a pub/sub channel per friend pair (A–B, A–C…) — fine for small lists. Large groups: don't push presence; fetch on demand when the group or member list opens. Best-effort, eventually consistent — a 10 s-stale dot harms nobody.
- Typing indicators: over the WebSocket only, never stored, client throttled (once every few seconds), auto-expire if "stopped" is lost.
13. End-to-end encryption — what changes
Ask: what disappears once the server only holds ciphertext?
Server still: routes ciphertext, holds it until delivery, distributes public key material. Signal protocol (WhatsApp): devices upload public prekeys; a sender fetches them to set up a session; private keys never leave devices.
| Area | With E2EE |
|---|---|
| Groups | Sender Keys — each member shares a sender key once (over pairwise sessions), then encrypts each message once; server fans out one ciphertext → §11 fan-out survives |
| Multi-device | client fan-out — sender encrypts separately per recipient device; server keeps account → device-list map |
| Server-side search | ❌ gone → search on device |
| History for a new device | ❌ server deletes after delivery → device-to-device transfer or encrypted backup |
| Moderation / spam on bodies | ❌ gone |
| Trust risk | malicious server adds its own device to a user's list → defense: key/device-change notifications ("security code changed") |
"E2EE barely changes delivery — the server still routes and fans out. It removes server-side search, history sync and moderation, and moves multi-device fan-out to the sender's client."
14. Failures
| Failure | Response |
|---|---|
| Chat server dies | clients reconnect via discovery with jittered backoff; registry entries stale → liveness check / TTL; cursor sync → nothing lost |
| Ack to sender lost | retry same client_msg_id; unique constraint returns existing message |
| Push to recipient lost (dead socket) | message stored; next sync delivers; sender stays at ✓ until then |
| Out-of-order arrival | client places by seq; gap → fetch missing |
| Hot conversation partition | request coalescing, data-services routing by conversation, cache recent window |
| Deploy | drain gradually, never hard-restart |
| Presence store loses data | heartbeats repopulate within 5 s — not system-of-record |
Message store + per-device cursors = system of record. Sockets, registry, presence and the live push are all rebuildable. (Same cache-vs-truth split as the KV-store and news-feed notes.)
15. End-to-end trace (narrate, then break)
A → "hi" → B (B: phone online on server 2, laptop asleep)
A's client: client_msg_id=u-77, show greyed, send over WebSocket → server 1
server 1: (A,u-77) new → conv 42 seq 41 → write (conv 42, bucket 7) → ack A ✓
→ delivery queue → registry: phone@server-2, laptop: none
server 2: local map → push {seq 41,"hi"} to phone → phone acks
→ last_delivered(B:phone) = 41 → tell A ✓✓
B opens chat: "read up to 41" (debounced) → last_read(B, conv 42)=41 → tell A blue
evening: laptop reconnects → cursor "conv 42 up to 38" → receives 39, 40, 41
BREAK 1 ack to A lost → A resends u-77 → unique constraint → existing seq 41 returned
→ one message, A gets its tick
BREAK 2 server 2 crashes after pushing, before the phone's ack
→ phone reconnects to server 3 (backoff) → syncs from cursor
→ (if the push was lost, cursor 40 brings 41 back) → late ✓✓, nothing lost or doubled16. Interview script
1. clarify 1:1 + group (cap?) · mobile + web · 50M DAU · presence · multi-device · history
E2EE? MAX GROUP SIZE? state guarantees: at-least-once+dedup,
per-conversation order, eventual presence ← unprompted
2. estimate 20k–100k msgs/s · ~150 TB/yr · 10M concurrent sockets → ~100 servers
3. protocol polling → long polling → WebSocket; cost = stateful servers
4. high level stateless (API, discovery) · stateful (chat, presence) · third-party (push)
5. discovery login → pick server by region + load → ws_url; reconnect storm, draining ← unprompted
6. routing session registry + RPC/queue + local socket map; pub/sub alternative;
stale registry is safe because routing ≠ guarantee
7. send flow order → persist → ack → queue → lookup → push or notify
8. acks three ticks, three confirmers; client_msg_id dedup; receipts as cursors
9. storage RDBMS for users; wide-column (conversation, bucket) DESC for messages;
hot partitions + request coalescing ← senior
10. ordering server-assigned; per-conversation seq; gap detection
11. sync per-device cursors = correctness; push = speed
12. groups inbox copies ≤ ~100–500; store once + publish to gateways for big channels
13. presence 5 s heartbeat, 30 s TTL key, push small / fetch on demand large; typing
14. E2EE Sender Keys, client fan-out per device, what's lost
15. walk it trace A → B, then kill the ack and the server yourselfSteps 1, 5, 6 and 9 separate a pass from a strong pass.
17. The lines that earn the most
- "Chat is delivery plus sync: history plus per-device cursors is the correctness mechanism; the WebSocket push only makes it fast."
- "WebSocket wins by elimination, and its cost is stateful servers — reconnect storms and draining are the price."
- "Persist before ack. One tick means stored, two means the device confirmed, blue means the app reported it read — three promises, three different confirmers."
- "Retries are inevitable, so the client generates a
client_msg_idand the server enforces uniqueness on (sender, client_msg_id)." - "The registry says where, RPC or a queue carries the bytes, and the local socket map finds the connection — and a stale registry is safe because routing isn't the guarantee."
- "I only need order within a conversation, so a per-conversation sequence is millions of independent counters, not a bottleneck — and it gives me gap detection."
- "Receipts are cursors — 'read up to X' — never per-message rows per recipient."
- "Group chat is the news-feed fan-out in real time: inboxes for small groups, store once and publish to gateways for big channels."
- "Presence is a TTL key refreshed by heartbeats — expiry is the offline detection, and it never touches the durable store."
- "E2EE keeps the routing but removes server-side search, history sync and moderation."
18. Numbers to remember
| Thing | Value |
|---|---|
| Book scope | 50M DAU · group ≤ 100 · text < 100,000 chars · history forever |
| Messages | 2×10⁹/day ≈ 20k/s avg, ~100k/s peak |
| Storage | ~200 B/message → ~400 GB/day → ~150 TB/yr raw (×3 replicated) |
| Concurrent connections | ~10M (20% of DAU) |
| Memory per connection | ~10 KB → ~100 GB total |
| Connections per box | plan ~100K (record: WhatsApp 2M+) |
| Registry lookup | ~0.5 ms (one datacenter round trip) |
| Heartbeat / timeout | every 5 s / offline after 30 s |
| Inbox fan-out ceiling | ~100–500 members |
| Discord | 12 Cassandra nodes (2017) → 177 nodes, trillions of messages (2022) → ScyllaDB |
| ScyllaDB p99 | history reads ~15 ms (was 40–125), inserts ~5 ms (was 5–70) |
19. Common follow-ups
| Follow-up | Answer skeleton |
|---|---|
| How does server A reach B on server B? | session registry → RPC / per-server queue → local socket map; or pub/sub per user (§6) |
| Registry stale? | message already stored → reconnect sync; TTL + server-liveness check shorten the window |
| Duplicate messages | client_msg_id unique per sender; receiver dedups by message ID |
| Ordering | server-assigned per-conversation seq; never client clocks; gap → fetch |
| Offline user | push notification nudge; cursor sync on reconnect |
| New device | sync from cursor 0 (or recent window); with E2EE: device-to-device or encrypted backup |
| 100k-member channel | store once, consistent-hash channel servers, publish to gateways; presence on demand |
| Read receipts in big groups | per-user cursor last_read_msg_id; debounced "read up to X" |
| Chat server crash / deploy | jittered reconnect, drain on deploy |
| Hot channel | request coalescing, routing layer by channel, cache recent window |
| Search | server-side index (Elasticsearch-style) — unless E2EE, then on device |
| Media | pre-signed upload to object storage; message carries URL + thumbnail; CDN |
| Typing indicator | ephemeral over WebSocket, throttled, auto-expire, never stored |
20. When NOT to go deep
Inside a bigger question ("design Uber" needing in-app chat), one sentence:
"Clients hold WebSockets to stateful chat servers chosen by service discovery; messages get a per-conversation sequence, are persisted to a wide-column store before acking, and are routed via a session registry — with per-device cursors syncing anything missed."
Go deep only when chat IS the question, or the interviewer pushes on delivery guarantees, groups or presence.
21. Glossary (A–Z, one line each)
| Term | Meaning |
|---|---|
| ack (sent / delivered / read) | three confirmations from server / recipient device / recipient app |
| ambiguous timeout | sender can't tell "never arrived" from "arrived, ack lost" → retries → need dedup |
| bucket | static time window in the partition key; caps partition size |
| channel server | stateful server owning a subset of channels via consistent hashing (Slack) |
| client_msg_id | UUID generated on the sending device; unique per sender → retry dedup |
| connection draining | moving clients off a server gradually before a deploy |
| conversation | unified model: a 1:1 chat is a conversation with two members |
cursor (cur_max_message_id) | newest message ID a device has; reconnect sync fetches everything newer |
| E2EE | end-to-end encryption; server sees only ciphertext |
| fan-out (inbox vs channel) | copy to each member's inbox (small) vs store once, publish to servers (large) |
| gap detection | missing seq number reveals a lost/delayed message |
| gateway server | stateful server holding users' WebSockets + subscriptions (Slack) |
| heartbeat | periodic client ping (5 s) refreshing presence |
| hot partition | one partition receiving disproportionate traffic, slowing the cluster |
| k-sortable | roughly time-ordered; Snowflake across servers |
| local socket map | in-process map device → socket object on one chat server |
| long polling | server holds a request open until data or timeout |
| message sync queue | per-user inbox queue (book's small-group fan-out) |
| per-conversation sequence | monotonic counter per conversation; strict order + gap detection |
| persist before ack | store durably before telling the sender "sent" |
| prekeys | public keys devices upload so senders can start encrypted sessions (Signal) |
| presence | online/offline status; best-effort, eventually consistent |
| pub/sub (sharded) | publish to a channel, subscribers receive; Redis 7 SPUBLISH routes per shard |
| reconnect storm | mass simultaneous reconnects after a server dies |
| request coalescing | collapsing identical in-flight reads into one backend query |
| Sender Keys | Signal group scheme: encrypt once per message, server fans out ciphertext |
| service discovery | picks a chat server for a client by region and load (ZooKeeper) |
| session registry | shared map user/device → chat server (Redis), TTL'd |
| SSE | Server-Sent Events: one-way server→client stream |
| stateful vs stateless | holds per-client connection state vs interchangeable behind an LB |
| TTL key | cache key that expires; presence expiry = offline detection |
| typing indicator | ephemeral, throttled, never stored |
| WebSocket | HTTP-upgraded persistent bidirectional connection |
| wide-column store | Cassandra/ScyllaDB/HBase: partitioned, sorted within partition, append-optimised |