Problem brief
A chat app like WhatsApp lets people send messages one-to-one and in small groups, see them arrive in real time, see who's online, and see sent / delivered / read ticks. The interesting parts aren't storage volume — they're keeping hundreds of millions of live connections open, routing a message to whichever server the recipient happens to be connected to, keeping every chat in a consistent order, and making sure a message still reaches a phone that was switched off when it was sent.
New to real-time messaging? Here's the whole idea in 30 seconds.
Normal web traffic is request → response: the phone asks, the server answers. That doesn't work for chat, because the server needs to tell Bob's phone "you have a new message" the moment Alice sends it — and the server can't start a plain HTTP request to a phone.
So every online phone keeps one long-lived WebSocket connection open to a gateway server. Either side can push data over it at any time.
That raises three questions the rest of this design answers:
- Routing: Alice is on gateway 1, Bob is on gateway 3. How does gateway 1 find Bob? → a session registry (user → gateway).
- Offline delivery: Bob's phone is off. Where does the message go? → it's stored first, an entry goes in Bob's inbox, a push notification wakes his phone, and he syncs on reconnect.
- Ordering: phone clocks disagree. How does everyone see the same order? → a per-chat sequence number assigned by the server.
Practice workspace
Guided practice or a locally saved 45-minute mock.
Step 1 of 10
Functional Requirements
What are the core flows, and what would you explicitly leave out of version one?
Reference solution hidden
Try answering in the workspace above first. Reveal the full worked solution — data model, architecture, and trade-offs — whenever you want to compare.
Open the full solution page →Reference solution walkthrough
The sections below are a worked solution, starting with requirements and continuing through the data model, architecture, and trade-offs. Use the practice workspace above first if you want to answer without seeing the reference.
Functional Requirements
The core features we need to support:
- One-to-one chats and small group chats (say, up to a few hundred members).
- Real-time delivery — if the recipient is online, the message shows up within a second or so.
- Offline delivery — if the recipient's phone is off, the message arrives when it comes back online, and they get a notification.
- Presence — show whether a contact is online / last seen.
- Delivery receipts — one tick (sent: the server has it), two ticks (delivered to the recipient's device), blue ticks (read).
- History — scroll back through a chat; a new device can load recent messages.
Example. Alice sends Bob "hi". If Bob is online, he sees it immediately and Alice's message flips from one tick to two. If Bob's phone is off, Alice sees one tick; Bob gets a push notification, and when he opens the app the message syncs down and Alice's tick turns into two.
Out of scope for a first pass: voice/video calls, the details of end-to-end encryption, stories/status, and very large broadcast channels. Mention them so the interviewer knows you're scoping on purpose.
Scale Requirements
Rough numbers to anchor the design (assumptions, not anyone's real figures):
- 500M daily active users, each sending about 40 messages/day → 20B messages/day.
- 20B / 86,400 s ≈ 230,000 messages/sec on average; plan for peaks of ~2× (≈460k/sec).
- At ~100 bytes per text message (plus metadata), that's about 2 TB/day of message data — media is stored separately in object storage.
- Say 200M phones are online at a busy moment. If one gateway server comfortably holds about 100,000 open WebSockets, that's roughly 2,000 gateway servers.
The takeaway: write throughput is high but manageable for a partitioned store. The genuinely hard, unusual part is holding hundreds of millions of long-lived connections and routing messages between them.
Non-Functional Requirements
- Durability: never lose a message. Once Alice sees one tick, the message must survive server crashes. That means store before ack.
- Ordering per chat. Everyone in a chat sees messages in the same order. There's no need for a global order across different chats.
- Low latency for online recipients — sub-second end to end.
- At-least-once delivery, deduplicated on the client. Networks drop acks, so messages will occasionally be resent; a client-generated message ID makes retries harmless.
- High availability for sending. Presence and "last seen" can be slightly stale — that's an acceptable trade.
Data Model
| Store | Shape | Notes |
|---|---|---|
| Messages | partition key chat_id, clustering key seq → sender_id, client_msg_id, body, media_url, server_ts | append-only; "last 50 messages" is one sorted range read |
| Chat counter | chat_id → last_seq | the single source of the next sequence number per chat |
| Inbox (undelivered) | user_id → list of (chat_id, seq) | entries are added when a recipient is offline, cleared on delivery ack |
| Receipts / cursors | (chat_id, user_id) → delivered_up_to, read_up_to | one number per member per chat, not one row per message |
| Session registry | user_id → gateway_id (+ TTL) | in-memory (e.g. Redis); also answers "is this user online?" |
| Group membership | chat_id → member list | small for normal groups |
Because receipts are stored as "up to sequence N", a read receipt for a whole screen of messages is a single update, not dozens.
API / Protocol
Most traffic flows over the WebSocket as small JSON (or binary) frames:
// client → server
{ "type": "send", "chat_id": "c_123", "client_msg_id": "a1b2", "body": "hi" }
{ "type": "ack_delivered", "chat_id": "c_123", "up_to_seq": 42 }
{ "type": "ack_read", "chat_id": "c_123", "up_to_seq": 43 }
{ "type": "sync", "have": { "c_123": 42, "c_456": 17 } }
// server → client
{ "type": "sent", "client_msg_id": "a1b2", "chat_id": "c_123", "seq": 42 }
{ "type": "message", "chat_id": "c_123", "seq": 42, "from": "alice", "body": "hi" }
{ "type": "receipt", "chat_id": "c_123", "delivered_up_to": 42, "read_up_to": 41 }
Plain HTTP endpoints are still useful for things that aren't real-time: login, uploading media (which returns a URL to put in the message), and loading older history pages: GET /chats/{chat_id}/messages?before_seq=1200&limit=50.
High-Level Design
Online recipient (the happy path):
- Alice's phone sends the message up her WebSocket to gateway 1.
- The chat service assigns the next
seqfor that chat (say 42) and writes the message to the message store. - Only after the write succeeds, Alice gets a
sentack → one tick. - The chat service looks Bob up in the session registry: he's on gateway 3. Gateway 3 pushes the message down Bob's WebSocket.
- Bob's phone acks; that receipt flows back to Alice → two ticks.
Offline recipient (store and forward):
- Alice sends message 43; it's stored exactly as before, and Alice gets one tick.
- The registry has no entry for Bob, so the chat service adds
(chat_id, 43)to Bob's inbox. - It sends a push notification through Apple's or Google's push service. This is only a wake-up hint — pushes can be delayed, collapsed or dropped — so it is never the delivery mechanism itself.
- When Bob reconnects, his phone sends a
sync: "in this chat I have everything up to 42". The server returns 43 from the message store. - Bob acks, the inbox entry is cleared, and Alice's message shows two ticks.
The key idea: the message store is the source of truth. Live pushes over WebSockets and push notifications are just ways of telling a device to come and get what's already stored.
Deep Dive: Gateways, Registry and Presence
- Gateways are stateful only in their sockets. They hold connections and forward frames; business logic lives in the chat service behind them. A load balancer spreads new connections across gateways.
- Session registry. On connect, the gateway writes
user_id → gateway_idwith a TTL. Phones send a small heartbeat every so often; each heartbeat refreshes the TTL. Miss a few heartbeats and the entry expires. - Presence comes for free. "Is Bob online?" is just "does Bob have a live registry entry?". "Last seen" is the time the entry was last refreshed. Presence is eventually consistent — a few seconds of staleness is fine.
- Don't broadcast presence to everyone. Push presence changes only to contacts who currently have that chat open, or let clients fetch it when they open a chat; otherwise one user going online fans out to their whole contact list.
- Gateway crash. Those phones' sockets drop; their apps reconnect (with backoff and jitter) to another gateway, re-register, and send a
syncwith their last knownseqper chat. Anything sent in between was already stored and lands in their inbox, so nothing is lost.
Deep Dive: Ordering with Sequence Numbers
Phone clocks are unreliable. If Alice's clock runs 5 minutes fast, her "lunch at 1?" (sent at real 12:00, stamped 12:05) sorts after Bob's reply "yes! where?" (stamped 12:02). Even server clocks on different machines drift by milliseconds, which matters for rapid back-and-forth.
Instead, one owner per chat hands out a monotonically increasing sequence number: 41, 42, 43. That number is the chat's order, for every participant. Ways to implement the owner: an atomic counter (INCR) keyed by chat_id, or route every send for a chat to the same chat-service partition (hash of chat_id) so it can assign numbers locally.
Sequence numbers buy you more than order:
- Gap detection. If Bob has 41 and 43, he knows 42 is missing and fetches it.
- Cheap sync. Reconnecting is "give me everything after seq N" per chat.
- Cheap receipts. "Read up to 43" covers every earlier message.
Retries and duplicates. If Alice's phone doesn't hear the sent ack, it resends. Each message carries a client-generated ID; the server keeps (chat_id, client_msg_id) → seq for a while and returns the existing seq instead of storing a second copy. Clients also ignore a seq they've already rendered.
Deep Dive: Delivery and Read Receipts
- One tick (sent): the server has durably stored the message and assigned a
seq. - Two ticks (delivered): the recipient's device acknowledged receiving it.
- Blue ticks (read): the recipient opened the chat with the message on screen.
Receipts are just tiny messages travelling the other way through the same path (gateway → chat service → sender's gateway, or the sender's inbox if they're offline). Store them per member as delivered_up_to / read_up_to cursors, so opening a chat with 30 unread messages sends one "read up to 43" receipt instead of 30. In groups, the sender's UI shows two ticks only when every member's cursor has passed that seq.
Deep Dive: Group Chat Fan-out
For normal groups (tens to a few hundred members):
- Alice sends once. The message is stored once under the group's
chat_idwith oneseq. - The chat service reads the member list and fans out: for each member, look up the registry — online members get it pushed down their socket; offline members get an inbox entry and a push notification.
- Receipts are tracked per member.
Storing once and fanning out pointers keeps writes proportional to one message, while delivery work is proportional to the group size — fine for small groups. For huge broadcast channels, per-member fan-out gets expensive, so switch to a pull model: members fetch "everything after seq N" when they open the channel, and notifications are batched or sampled. (Same trade-off as fan-out on write vs read in Design Twitter.)
Deep Dive: Message Storage
The access pattern is very regular: append new messages to a chat, and read a contiguous range of a chat (latest page, or "after seq N"). That fits a wide-column store such as Cassandra or a similar LSM-based store:
- Partition key
chat_id— all of a chat's messages live together and spread evenly across nodes. - Clustering key
seq— rows are kept sorted, so "the last 50 messages" or "everything after 42" is a single range read. - Write-heavy, append-only — LSM-tree storage handles high write rates well, and messages are rarely updated.
- Hot or very long chats — bucket the partition by
seqrange (e.g.(chat_id, seq / 10_000)) so no single partition grows without bound.
Media (photos, voice notes, video) is uploaded separately to object storage; the message only carries a URL and metadata, served through a CDN. Retention policy is a product decision — some apps keep history on the server, others delete it once every device has it.
Wrap-Up & Trade-offs
A solid answer hits these points:
- WebSockets to stateless-ish gateways, with a session registry (user → gateway) that doubles as presence.
- Store first, then ack — one tick means durably stored; nothing is delivered that isn't stored.
- Per-chat sequence numbers, not wall clocks, for ordering, gap detection, sync and receipts.
- Offline = inbox + push + sync — push notifications only wake the phone; the device pulls everything after its last known
seq. - At-least-once delivery with client message IDs for deduplication.
- Groups store once and fan out for small groups; switch to pull for huge channels.
- Message store partitioned by
chat_id, clustered byseq.
Common follow-ups: multi-device sync (each device keeps its own cursor), end-to-end encryption (the server stores and forwards ciphertext it can't read, which pushes features like search to the client), cross-region routing, and how to roll out gateway deploys without dropping millions of connections at once (drain gradually, let clients reconnect with jitter).