4. Chat System (WhatsApp) Advanced

A. Realistic Interview Prompt

"Design a real-time chat system like WhatsApp: 1:1 and group messaging, message delivery even when the recipient is offline, and delivery status (sent/delivered/read)."

Unlike the previous three problems, the core challenge here isn't a request/response API at all — it's maintaining long-lived, stateful connections for hundreds of millions of concurrent users, and routing a message to whichever specific server happens to hold the recipient's connection right now. That routing problem — "which of my N servers is this user actually connected to" — is the single idea that unlocks the rest of the design.

B. Difficulty

Advanced from the first sentence — there is no simple V1 that's merely "naive," because even the most basic version of this problem requires picking a connection model (polling vs. WebSockets) before anything else can be discussed. This problem is closer in spirit to Uber than to URL Shortener or Rate Limiter: the naive design is naive specifically because it picks the wrong transport, not because it skips a later optimization.

C. What This Problem Tests

D. Requirements

Functional

  1. Users can send/receive 1:1 messages in real time when both are online
  2. Messages sent while the recipient is offline are delivered once they reconnect (store-and-forward)
  3. Users can create groups and send messages that fan out to all members
  4. Delivery status is tracked and shown to the sender: sent → delivered → read

Non-Functional

Out of scope: voice/video calls, status/stories, payments — each is its own design problem layered on the same connection infrastructure.

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1Is end-to-end encryption required, or is server-side plaintext acceptable?E2E fundamentally restricts what the server can do with content (search, previews) and how multi-device sync worksDeep Dive 7 (S)
2What's the max practical group size?Determines whether group fanout can reuse 1:1 delivery unmodified or needs its own pathDeep Dive 5 (Q)
3Do we need "read receipts," or just "delivered"?Read receipts require a second round-trip of status updates back to the senderData Model (H)
4Single device per user, or multi-device sync (phone + web + desktop simultaneously)?Multi-device turns "deliver to a user" into "deliver to a set of the user's active sessions"Deep Dive 7 (S)
5How long should undelivered messages be retained before giving up?Bounds the store-and-forward queue's storage requirementDeep Dive 6 (R)
6Is strict ordering required across a server failover, or is "usually ordered" acceptable?Strict ordering needs a per-conversation sequence number; "usually" can get away with timestamps aloneDeep Dive 3 (O)

F. Assumptions

G. Capacity Estimation

MetricValue / CalculationWhy It Matters
DAU500MBase input
Peak concurrent connections~150M (30% of DAU online simultaneously at peak — a standard, illustrative ratio)This is the number that actually sizes the connection-server fleet, not DAU itself (M)
Messages/day500M users × ~40 messages/day ≈ 20B/dayBase write volume
Message write QPS20B ÷ 86,400s ≈ ~230K/sec average (peak 3-5×, ~1M/sec)Sets the throughput bar for the message-ingest and routing path
Connections per serverA single well-tuned server can hold roughly 1-2M concurrent WebSocket connections (a widely-cited, illustrative figure for event-loop-based connection servers)150M ÷ ~1M per server ≈ ~150 connection servers — the concrete fleet size driving the routing problem in N
Message size~1KB average (text; media handled like Twitter's R — blob store + CDN, not inline)Confirms text messages themselves are cheap to store; media is the real storage driver, out of scope for the core design
Storage/day (text only)20B × 1KB ≈ ~20TB/dayNon-trivial — motivates a scalable wide-column store, not a single relational instance (K)

H. Core Entities

EntityPurposeKey Fields
UserAn accountuser_id, devices (for S)
ConversationA 1:1 or group threadconversation_id, type (1:1/group), member_ids
MessageOne sent messagemessage_id, conversation_id, sender_id, seq_num (O), content (ciphertext under S), created_at
DeliveryStatusPer-recipient status of one messagemessage_id, recipient_id, status (sent/delivered/read), updated_at
Session (ephemeral)Which connection server a user is currently attached touser_id, server_id, connected_at — lives in a fast shared store (N), never durable (K)

I. API Design

WebSocket events (bidirectional, over a persistent connection):
  CLIENT -> SERVER: send_message { conversation_id, client_msg_id, content }
  SERVER -> CLIENT: message_ack { client_msg_id, message_id, seq_num }
  SERVER -> CLIENT: new_message { message_id, conversation_id, sender_id, seq_num, content }
  CLIENT -> SERVER: ack_delivered { message_id }
  CLIENT -> SERVER: ack_read { message_id }
  SERVER -> CLIENT: status_update { message_id, status }

REST (non-real-time concerns):
  POST /v1/conversations           { member_ids } -> { conversation_id }
  GET  /v1/conversations/{id}/history?cursor=&limit=

Why a separate client_msg_id from the server-assigned message_id: the client generates its own ID before the network round-trip completes, so it can optimistically render the message as "sending" immediately and later reconcile it with the server's authoritative ID and sequence number — the same idempotency-key pattern used to make retries safe elsewhere in distributed systems (a client that doesn't hear back and retries the send won't create a duplicate message, because the server can dedupe on client_msg_id).

J. Basic / Naive High-Level Design (V1)

The simplest thing that satisfies real-time delivery in isolation: clients hold a persistent WebSocket to a single connection server; that server looks up the recipient's connection on itself and pushes the message directly.

flowchart LR A(["User A"]) -- "WebSocket" --> S1["Connection Server"] B(["User B"]) -- "WebSocket" --> S1 S1 -- "A sends to B, same server: works" --> B

V1 — works only when sender and recipient happen to be connected to the exact same server.

Why this breaks immediately, not eventually: at any real scale there are ~150 connection servers (G), and a message's sender and recipient are connected to two different servers almost every single time — there is no shared state telling Server 1 that User B is actually attached to Server 47. This is the WhatsApp-specific version of Rate Limiter's V1 problem: correctness that silently depends on "only one server exists" is not a simplification, it's a design that doesn't function at all once a second server is added — the exact motivation for the routing layer in N.

K. Data Model & Store Choice

Messages/conversations: a wide-column store (Cassandra-class), partitioned by conversation_id and clustered by seq_num/timestamp — this access pattern ("give me this conversation's messages in order") is exactly what a wide-column store's partition+clustering-key model is built for, at the write throughput (G) a relational single-instance store would struggle to sustain.

Session/routing state (Session): Redis or ZooKeeper — small, ephemeral, extremely hot-path data (looked up on every single message send) that must be shared across every connection server; losing it on a restart is recoverable (the client simply reconnects and re-registers), so it doesn't need durability guarantees, mirroring the ephemeral Counter reasoning from Rate Limiter's data model.

L. End-to-End Flow — 1:1 Message, Both Users Online

sequenceDiagram participant A as User A participant S1 as Connection Server 1 participant Route as Session/Routing Store participant MQ as Message Broker participant S2 as Connection Server 2 participant B as User B A->>S1: send_message(conversation_id, content) S1->>S1: persist message (K), assign seq_num (O) S1-->>A: message_ack S1->>Route: lookup session(B) Route-->>S1: {server_id: S2} S1->>MQ: publish to S2's channel MQ->>S2: deliver S2->>B: new_message (push) B->>S2: ack_delivered S2->>S1: status_update(delivered) S1-->>A: status_update(delivered)

M. Deep Dive 1 — Connection Management at Scale

Requirement this addresses: the concurrent-connection NFR from D and G — 150M simultaneous long-lived connections is the foundational constraint everything else builds on.

Why WebSockets, not HTTP polling or long-polling: polling means every idle client repeatedly asks "anything new?", burning connections and server cycles on empty answers — the inverse of what a <100ms real-time NFR needs. A WebSocket is a single persistent, bidirectional connection: the server can push the instant a message arrives, with zero polling overhead. Long-polling is a middle ground (better than plain polling) but still re-establishes a connection per round-trip; a full WebSocket avoids that entirely.

Why connection servers must be a distinct tier from stateless API/business-logic servers: a WebSocket connection is stateful — it lives on one specific machine for its entire duration, unlike a stateless HTTP request that any server can handle. This single fact is why a load balancer in front of connection servers must support sticky sessions or connection-aware routing (a plain round-robin LB would silently break an in-progress WebSocket), and it's why connection servers are event-loop-based (handling many thousands of idle connections cheaply) rather than thread-per-connection.

N. Deep Dive 2 — The Routing Problem

Requirement this addresses: directly fixes V1's fatal flaw (J) — this is the single idea that makes the whole system work past one server.

flowchart TB A(["User A"]) --> S1["Connection Server 1"] B(["User B"]) --> S47["Connection Server 47"] S1 -- "1. lookup B's server" --> Route[("Session Store
user_id -> server_id")] Route -- "2. {server_id: 47}" --> S1 S1 -- "3. route via broker" --> Broker["Message Broker /
Pub-Sub"] Broker -- "4. deliver" --> S47 S47 --> B

Every connection server registers/deregisters itself in the shared Session Store on connect/disconnect — the routing layer is what V1 was missing entirely.

Why a shared session store, not server-to-server broadcast: the naive alternative to a lookup table is for Server 1 to ask every other server "do you have User B?" — an O(N) broadcast per message that gets worse as the fleet grows, exactly backwards. A shared, fast key-value lookup (user_id → server_id) turns routing into O(1) regardless of fleet size — the same "look it up, don't ask everyone" principle behind consistent hashing in Rate Limiter, just for connection ownership instead of counter shards.

Why a message broker/pub-sub layer between connection servers, not a direct server-to-server call: connection servers come and go (deploys, crashes, scaling events); a pub-sub layer (Redis Pub/Sub, or Kafka for durability) decouples "which server has User B right now" from "how do I get a message to that server," and gives the delivery attempt a natural retry/redelivery point if the target server was mid-restart.

O. Deep Dive 3 — Message Ordering

Requirement this addresses: the strict per-conversation ordering NFR (D) — network delivery alone gives no ordering guarantee once messages can take different paths through different servers.

Why wall-clock timestamps aren't sufficient on their own: clock drift between servers (the identical problem named in Rate Limiter's atomicity deep dive) can make two messages sent moments apart appear out of order if each is timestamped by a different connection server's local clock.

The fix — a per-conversation monotonic sequence number, assigned at the point of persistence (not by the sending client): when a message is written to the source-of-truth store (K), it's assigned the next seq_num for that conversation_id, atomically (an auto-incrementing counter scoped to the conversation, analogous to the atomic counter reasoning in URL Shortener's ID-generation deep dive). Clients then order and render messages by seq_num, never by arrival time or local timestamp — arrival order across a distributed system is not delivery order, and delivery order is not send order, but seq_num pins down a single authoritative order regardless of network path.

P. Deep Dive 4 — Delivery Guarantees (Sent / Delivered / Read)

Requirement this addresses: the "no message loss" NFR (D) plus the specific three-state status UX in the functional requirements.

StatusSet WhenMechanism
SentThe server has durably persisted the message (K)Server acks the client immediately after the write — this is the point at which "no message loss" starts being guaranteed, not before
DeliveredThe recipient's device has received it over the WebSocketRecipient's client sends an explicit ack_delivered — receiving the push alone isn't proof, since the push could be sent right as the connection drops
ReadThe recipient has opened/viewed the messageA separate, later ack_read — deliberately decoupled from delivered, since "received" and "seen" are genuinely different events at different times

Why explicit client acks, not server-side inference: the server pushing a message is not proof the client received it — the WebSocket could close in the exact window between the server's write and the client's actual receipt. Each status transition requires an explicit signal from the receiving side, the only party that actually knows the true state — the same "don't infer completion, confirm it" principle behind any at-least-once delivery system's ack protocol.

Q. Deep Dive 5 — Group Chat Fanout

Requirement this addresses: the group-messaging functional requirement (D) — structurally similar to the News Feed problem's fanout question, but under much stricter latency/ordering constraints.

Why fanout-on-write is the only real option here (unlike News Feed's hybrid): News Feed could defer celebrity fanout to read time because a feed read tolerates a live merge; a group chat message must be pushed to every member's active connection immediately to hit the <100ms real-time NFR — there's no equivalent of "read-time merge" for a live conversation. On send, the message is persisted once (K), then the routing lookup (N) is repeated once per group member, each resolved to its own connection server independently.

Why this doesn't need News Feed's celebrity carve-out: group sizes are bounded (E's clarifying question 2 — typically capped in the hundreds, not millions of members), so the fanout width is naturally small; the unbounded-fanout problem that forced News Feed's hybrid design simply doesn't arise here at realistic group sizes. A group with an unusually large member cap would reintroduce that exact problem and warrant revisiting the same hybrid pattern.

R. Deep Dive 6 — Offline Delivery (Store-and-Forward)

Requirement this addresses: the offline-delivery functional requirement (D) — the routing lookup in N fails cleanly (no session found) when the recipient isn't connected at all, and this deep dive is what happens next.

flowchart LR Send["Message send"] --> Lookup{"Session lookup (N):
recipient online?"} Lookup -- "yes" --> Push["Push via connection server"] Lookup -- "no" --> Queue[("Per-recipient offline queue")] Queue -.-> Reconnect["Recipient reconnects"] Reconnect --> Drain["Drain queued messages
in seq_num order (O)"] Drain --> Push

Why a per-recipient queue, not just relying on the message store (K) alone: the message table already durably has every message (K) — but "durably stored somewhere" and "the client knows to fetch it" are different problems. On reconnect, the client (or server, on its behalf) needs an efficient way to ask "what's new since I was last online," which a per-recipient offline/undelivered-message index answers directly, without scanning every conversation's full history.

Bounding retention: undelivered messages are retained for a finite window (E's clarifying question 5 — WhatsApp's own real-world limit is illustrative here, on the order of weeks) before being dropped from the offline queue, bounding its storage; the message itself may still exist in the durable store for the sender's own history even after the offline-delivery attempt is abandoned.

S. Deep Dive 7 — End-to-End Encryption & Multi-Device

This is the hardest, most differentiating extension in this problem — analogous to Rate Limiter's multi-region deep dive in that it's genuinely open-ended and worth reasoning through explicitly rather than asserting a pat answer.

What E2E encryption changes structurally: once content is encrypted client-side before it ever reaches the server, the server can route, order, and store ciphertext — but can no longer read content, meaning server-side search, content-based moderation, and rich previews all become impossible or must move entirely to the client. This is a direct trade-off against product features, not a pure security upgrade with no cost.

The Signal Protocol, at a level relevant to this interview:

Why this belongs in the deep dive and not the base design (F): designing this correctly from scratch in an interview is unrealistic in the time available; the strong signal is knowing that this is what real systems (WhatsApp/Signal) actually do, why store-and-forward (R) and multi-device both get materially harder under E2E, and being able to reason about the trade-off — not deriving the Double Ratchet algorithm live.

T. Full Solution at Scale — Final Architecture

flowchart TB A(["User A
(sender)"]) -- "WebSocket" --> S1["Connection Server 1 (M)"] B(["User B
(recipient)"]) -- "WebSocket" --> S47["Connection Server 47 (M)"] S1 --> Persist[("Message Store (K)
assigns seq_num (O)")] S1 -- "lookup" --> Route[("Session/Routing Store (N)")] Route -. "server_id: 47" .-> S1 subgraph BROKER [" Pub-Sub / Message Broker (N) "] direction LR MQ["Delivery channels per connection server"] end S1 --> BROKER BROKER --> S47 S47 --> B B -- "ack_delivered / ack_read (P)" --> S47 S47 -.-> S1 Persist -.-> Offline[("Offline Queue (R)
if recipient not connected")] Offline -.-> S47 A -. "content encrypted client-side (S)" .-> S1

Everything above is the composition of M through S — no new component introduced here.

U. Design Evolution

What stays stable throughout: the WebSocket event contract from I (send_message/new_message/status events) — every version underneath changes without the client protocol needing to change.

V. Consistency Model

Ordering (O) and delivery (P/R) require much stronger guarantees than either previous problem's "approximate is fine" stance — a chat message that's silently dropped, duplicated, or shown out of order is immediately visible and confusing to the user, unlike a rate limiter briefly over-counting or a feed appearing a few seconds late. The per-conversation seq_num is a genuine strong-ordering guarantee, not an approximation; delivery is effectively at-least-once with client-side dedup (via client_msg_id, I) rather than exactly-once, which is the pragmatic, honest target — true exactly-once delivery across an unreliable network is not achievable without the receiver doing dedup work regardless, so the design leans into that instead of chasing an unachievable guarantee.

W. Failure Scenarios

X. Bottlenecks & Scaling Boundary

StageActual BottleneckFix Applied
V1 single-server routingNo cross-server delivery at all — broken with >1 serverShared session store + broker (N)
Cross-server delivery, no seq numbersOrdering not guaranteed across servers/network pathsPer-conversation atomic seq_num (O)
Ordered delivery, single-deviceGroup fanout width and per-device fanout under encryption both multiply message-send costBounded group size (Q) + accepted fanout cost under E2E (S)
Fully online-only designMessage loss when the recipient is offlineOffline queue / store-and-forward (R)

Y. Design Trade-Offs

DecisionOption AOption BTrade-off
TransportHTTP polling/long-pollingWebSockets (chosen)Polling is simpler infra but structurally can't meet the real-time latency NFR; WebSockets meet it at the cost of stateful, sticky connections
OrderingClient/wall-clock timestampsServer-assigned seq_num (chosen)Timestamps are simpler but vulnerable to clock drift; seq_num is authoritative at the cost of one atomic counter step per message
Delivery guaranteeExactly-once (attempted)At-least-once + client dedup (chosen)True exactly-once across an unreliable network is not practically achievable; at-least-once with idempotent client dedup is the honest, achievable target
Group fanoutHybrid (News-Feed-style)Uniform fanout-on-write (chosen)Hybrid solves an unbounded-fanout problem that doesn't exist here given bounded group sizes — added complexity with no matching benefit
EncryptionServer-side plaintextClient-side E2E (chosen per F)Plaintext enables server-side search/moderation/previews; E2E removes server visibility entirely, at the cost of multi-device fanout complexity

Z. Common Bad Designs

AA. Interviewer Follow-Ups

Follow-UpDesign ChangeComponents ChangedComponents Unchanged
"How do you know a connection server is still alive?"Heartbeat/ping-pong over the WebSocket; session store entries expire (TTL) if not refreshedSession store (N) gains a liveness/TTL mechanismRouting, ordering, delivery logic
"Support 'typing...' indicators."A separate, best-effort, non-persisted event type over the same WebSocket — deliberately not durable, unlike real messagesNew lightweight event type onlyMessage store, ordering, delivery guarantees
"What if a user is in 50 groups and each gets busy at once?"Per-user delivery is naturally rate-smoothed by the connection server's own send queue; no special-case needed beyond Q's existing fanoutNone structurally newEverything
"Add message search."Same lesson as News Feed's search deep dive — but critically constrained by S: if E2E, search must happen client-side over locally-decrypted content, not server-sideClient-side index (if E2E) or server-side index (if not)Routing, ordering, delivery
"A user reports messages arriving out of order."Debug via seq_num vs. arrival time — was this a genuine ordering bug, or just a display/reconnect artifactNone — this is why O's explicit seq_num existsEverything

AB. Interviewer Evaluation Rubric

AC. Interview Simulation

Interviewer: Design a chat system like WhatsApp — 1:1 messaging, group chat, offline delivery, and read receipts.
Candidate: The first decision is the transport. Polling can't hit real-time latency without wasting a huge amount of capacity on empty checks, so I'd use persistent WebSocket connections between each client and a connection server.
Interviewer: Fine. User A sends a message to User B — walk me through it.
Candidate: A's message hits whichever connection server A is attached to. That server persists the message and assigns it a sequence number for ordering. Then it needs to find out which connection server User B is actually attached to — that's not something A's server knows on its own, so I'd keep a shared session store, essentially a map from user ID to connection-server ID, updated whenever someone connects or disconnects.
Interviewer: Why not just have A's server ask every other server directly?
Candidate: That's an O(N) broadcast per message, and it gets worse as the fleet grows — a shared lookup makes it O(1) regardless of how many connection servers exist.
Interviewer: User B is offline. What happens?
Candidate: The session lookup comes back empty, so instead of pushing immediately, the message goes into a per-recipient offline queue. When B reconnects and re-registers in the session store, the server drains that queue to them in sequence-number order.
Interviewer: How do delivered and read status actually work?
Candidate: Both are explicit acknowledgments from the recipient's client, not something the server infers. "Delivered" fires when B's client confirms it received the push; "read" fires later, separately, when B actually opens it. The server pushing a message isn't proof B got it — the connection could drop in exactly that window — so I don't want to report a status the server can't actually verify.
Interviewer: Last one — what if this needs to be end-to-end encrypted?
Candidate: That changes what the server is allowed to do with the content — it can still route and order ciphertext, but it can no longer search it or generate previews from it. I'd point to something like the Signal Protocol: X3DH to establish keys even with an offline recipient, which matters a lot given the offline-queue requirement, and a Double Ratchet so each message gets its own derived key. The real complication is multi-device — each device has its own keys, so the sender actually has to encrypt and send a separate copy per recipient device, which multiplies the fanout cost on top of group chat's own fanout.

AD. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
Hello InterviewWebSocket + routing-layer framing, explicit "which server owns this connection" problem statement, staff-level encryption discussionClearest articulation of the routing problem as the central idea, exactly as used in NLighter on the specific Signal Protocol sub-components than a security-focused source would beThe requirements-driven framing of routing as the make-or-break idea, adopted for this whole section
ByteByteGoDetailed connection-server sizing, message-sync/queue design for multi-device, sequence-number orderingMost concrete numeric grounding for connections-per-server used in GTreats encryption more briefly than the routing/ordering mechanicsThe connections-per-server sizing figure and the sync-queue framing reused in M and R
dilipkumar2k6/messaging-chat-app-system-design (GitHub)Concrete worked numbers for connection counts, message throughput, and storage sizing specific to a chat systemThe most detailed openly-available numeric capacity-planning example found for this problemLess rigorous on the encryption/multi-device angleCross-checking the capacity estimation numbers in G against an independently-worked example
Signal's own protocol documentation (X3DH, Double Ratchet, Sesame)The actual, real-world cryptographic protocol WhatsApp's encryption is built onThe only source that's the actual ground truth for S rather than a system-design summary of itWritten for a security audience, not a system-design-interview audience — needs translation, which S providesUnderstanding X3DH's async-key-exchange property is precisely why offline delivery and E2E encryption aren't in conflict

AE. Interview Cheat Sheet

ProblemChat System (WhatsApp)
DifficultyAdvanced (no simple naive V1 — the transport choice is itself advanced)
Core requirements1:1 + group real-time messaging; offline delivery; sent/delivered/read status; 500M DAU, ~150M concurrent connections, <100ms delivery, strict per-conversation ordering
Central decisionWebSockets (not polling) + a shared session/routing store to solve "which server holds this user's connection"
Ordering fixServer-assigned, per-conversation monotonic sequence number — never client timestamps
Delivery guaranteeAt-least-once + client-side dedup via client_msg_id — honest target, not attempted exactly-once
Offline handlingPer-recipient offline queue, drained in seq_num order on reconnect, bounded retention window
Group chatUniform fanout-on-write — bounded group size means no celebrity-style carve-out needed
EncryptionSignal Protocol (X3DH + Double Ratchet + Sesame) — server routes/stores ciphertext only; multi-device multiplies fanout per recipient device
Common mistakesPolling instead of WebSockets; no shared routing state; timestamp-based ordering; inferred (not acked) delivery status
What NOT to over-engineerDeriving the Double Ratchet algorithm from scratch — name the protocol and its properties, don't design a cipher live