"Design WhatsApp" is one of the most common mobile system design questions. It looks simple: one person sends a message, another receives it. On a phone, it runs into most of the hard problems in mobile engineering.
The app needs two-way real-time streaming that doesn't drain the battery. Messages must stay in order when phone clocks disagree. Nothing can be lost when the network drops mid-send. And people still need to be reached when the app is closed.
This walkthrough answers the question the way you would in a 45-minute interview, in five steps:
- Clarify the requirements
- Design the API and data model
- Sketch the client architecture
- Deep dive into the hard problems
- Wrap up with trade-offs
The focus is the mobile client. The backend appears only where the app depends on it.
Step 1: Clarify the Requirements
Don't start drawing. "Chat" could mean one-to-one texting, group channels, voice calls, or all of them. Agree on the scope with the interviewer first.
Functional requirements
For a 45-minute session, a sensible scope is:
- A chat list showing recent conversations, newest first, with unread counts
- One-to-one text messages, sent and received in real time
- Online status and a typing indicator inside a conversation
- Message status ticks: sent, delivered, and read
- Message history available offline
- Push notifications when the app is in the background or closed
Non-functional requirements
| Requirement | Target | What it means for the client |
|---|---|---|
| Scale | About 30 million daily users | Keep payloads small and reconnects cheap |
| Latency | Messages appear in under a second on a good network | A persistent connection, not polling |
| Reliability | Zero lost messages | Every outgoing message is saved to disk before sending |
| Battery | No noticeable drain while idle | One connection, no timers waking the radio |
| Data ownership | The phone holds the real history | The local database is the source of truth |
Data ownership shapes the rest of the design. WhatsApp uses the server mainly as a relay, so storage, ordering, and sync logic live on the phone.
Out of scope
To leave time for the hard parts, set these aside: sign-up and contacts sync, group chats, media and voice notes, calls, and the details of end-to-end encryption. Mention that each is a natural extension.
Step 2: Design the API and Data Model
Traffic in a chat app flows both ways. The client starts some actions: sending a message, syncing missed ones. The server starts others: a new message arrives, someone is typing, a message was read. HTTP alone can't deliver server-started events, because the app never asked for them.
Choosing the protocol
| Option | Verdict for chat on mobile |
|---|---|
| Short polling | Rejected. Constant requests keep the radio awake and drain the battery |
| Long polling | Workable, but millions of held requests are hard to balance across servers |
| Server-Sent Events | Rejected. Server-to-client only, so sending still needs another channel |
| WebSocket | Chosen. One persistent two-way connection with tiny per-message overhead |
The design uses a hybrid: HTTPS REST for sending and syncing, and one encrypted WebSocket for server-pushed events. REST gives sends clear success and failure codes, retries, and auth. The socket stays small and focused, which makes it easier to scale and to reconnect.
REST endpoints
POST /v1/messages
Authorization: Bearer <token>
{
"requestId": "6f1c...e2", // client-generated UUID (idempotency key)
"toUserId": "u_482",
"content": "See you at 7",
"clientCreatedAt": 1761980400000
}
→ 201 Created
{ "requestId": "6f1c...e2", "messageId": "1853920018473312256", "serverCreatedAt": 1761980400412 }
GET /v1/messages?afterMessageId=1853920018473312256&limit=100
→ 200 OK { "messages": [ ... ], "hasMore": false }
POST /v1/messages/receipts
{ "messageIds": ["..."], "type": "DELIVERED" | "READ" }
WebSocket events
wss://chat.example.com/v1/socket
{ "type": "new_message", "payload": { "messageId": "...", "fromUserId": "...", "content": "..." } }
{ "type": "receipt", "payload": { "messageIds": ["..."], "status": "DELIVERED" | "READ" } }
{ "type": "typing", "payload": { "userId": "...", "isTyping": true } }
{ "type": "presence", "payload": { "userId": "...", "online": false, "lastSeenAt": 1761980400000 } }
Explain these three decisions to the interviewer:
-
The client generates
requestId. If a send times out and the app retries, the server recognises the duplicate and returns the original result instead of storing the message twice. -
The server generates
messageIdand the timestamp. Phone clocks can be wrong or changed by the user, so the server is the authority on order. -
Sync is cursor-based.
afterMessageIdasks for everything after the last message the phone has, so a reconnect fetches exactly what was missed.
Message states
Every outgoing message moves through one state machine, and the UI simply renders the current state:
PENDING ──send ok──▶ SENT ──receipt──▶ DELIVERED ──receipt──▶ READ
│
└──retries exhausted──▶ FAILED ──user taps retry──▶ PENDING
| State | UI |
|---|---|
| PENDING | Clock icon |
| SENT | One grey tick |
| DELIVERED | Two grey ticks |
| READ | Two blue ticks |
| FAILED | Red warning with "Tap to retry" |
Step 3: Sketch the Client Architecture
The app splits into a UI layer and a data layer, with data flowing one way: actions go down, state comes up.
UI LAYER
ChatListScreen → ChatListViewModel
ConversationScreen → ConversationViewModel
│ actions ↓ ↑ state (Flow / Combine)
DATA LAYER
ConversationsRepository (chat list, unread counts)
MessagesRepository (history, sending, receipts)
PresenceRepository (online + typing, memory only)
│
├─ MessagesApi REST: send, sync, receipts
├─ SocketClient WebSocket: events in, typing out
├─ ChatDatabase SQLite via Room / GRDB
└─ SendScheduler background worker for the outbox
The local database is the source of truth
The screens never read from the network. They observe the local database, and everything else writes into it:
- A message the user sends is written to the database first, then sent.
- A message arriving over the socket is written to the database, then shown.
- A sync after reconnecting writes the missed messages to the database.
The UI reacts to database changes, so one code path draws every message, whatever its origin. Offline history works automatically, and two screens can never disagree.
class MessagesRepository(
private val dao: MessageDao,
private val scheduler: SendScheduler,
) {
fun conversation(peerId: String): Flow<List<Message>> =
dao.observeConversation(peerId) // the only thing the UI reads
suspend fun send(peerId: String, text: String) {
dao.insertOutgoing(
OutgoingMessage(
requestId = UUID.randomUUID().toString(),
toUserId = peerId,
content = text,
status = Status.PENDING,
createdAt = System.currentTimeMillis(),
)
)
scheduler.kick() // send in the background
}
}
Why presence lives in memory
Online status and typing indicators change every few seconds and are useless a minute later. Writing them to disk wastes battery and flash storage. PresenceRepository keeps them in memory only, and they're rebuilt from socket events after every reconnect.
Threading
All database work, JSON parsing, and socket handling run off the main thread: Dispatchers.IO on Android, actors and background tasks on iOS. The main thread only renders state. When a busy conversation receives many messages at once, they're written in a single transaction, so the list updates once instead of fifty times.
Step 4: Deep Dive into the Hard Problems
Choose the problems that matter most for this app and lead the discussion. For WhatsApp, I'd raise these six.
1. Message ordering
Messages can arrive out of order because of network delays, offline sends, and phones whose clocks disagree.
- Sorting by the phone's clock shows messages instantly, but clocks drift and users can change them.
- Sorting by server ID is consistent everywhere, but an offline message has no server ID yet.
The fix combines both. Confirmed messages are ordered by a time-sortable server ID, such as a 64-bit Snowflake ID, where a newer message always has a higher number. Pending messages are ordered by local time and pinned to the bottom with a clock icon. When the server confirms one, it takes its real ID and settles into its correct position.
SELECT * FROM messages WHERE peer_id = :peer
ORDER BY
CASE WHEN server_id IS NULL THEN 1 ELSE 0 END, -- pending last
server_id,
local_created_at;
2. The send pipeline
A message must survive the app being killed, the phone rebooting, or the network dropping mid-request. So it's written to disk before anything is sent:
- The user taps send. A row is inserted with status
PENDINGand a newrequestId. The bubble appears instantly. - The background
SendScheduler(WorkManager on Android,BGTaskSchedulerplus a URLSession background task on iOS) picks up pending rows in order. - It sends
POST /v1/messageswith the samerequestIdon every attempt. - On
201 Created, one transaction stores the server'smessageIdand sets the status toSENT. - On a network error, the row stays
PENDING, its retry count increases, and the scheduler waits with exponential backoff and jitter: about 1, 2, 4, then 8 seconds, up to a cap. - After too many attempts, the row becomes
FAILEDand the bubble shows "Tap to retry".
fun backoffDelayMs(attempt: Int): Long {
val base = 1_000L shl attempt.coerceAtMost(6) // 1s, 2s, 4s ... 64s
val jitter = Random.nextLong(0, base / 2) // spread retries out
return (base + jitter).coerceAtMost(60_000L)
}
The jitter matters at scale. Without it, every phone that lost connection in the same outage retries at the same instant and overloads the server again.
The requestId makes retries safe. If the first request actually reached the server but the response was lost, the retry returns the original messageId instead of creating a second message.
3. Delivery and read receipts
The ticks come from receipts flowing back from the recipient:
- When the recipient's phone stores an incoming message, it sends a
DELIVEREDreceipt. - When the conversation is on screen and the message is visible, it sends a
READreceipt.
Receipts are batched. Opening a chat with 40 unread messages sends one request with 40 IDs, not 40 requests. When a receipt arrives for the sender, the client updates the status column, and the ticks change through the normal database flow.
A receipt can only move a message forward, never back. A late DELIVERED that arrives after READ is ignored.
4. The socket lifecycle
A WebSocket on a phone dies often: tunnels, Wi-Fi handoffs, the OS suspending the app. The client must manage it carefully:
- Connect only while the app is in the foreground. In the background, rely on push notifications to save battery.
- Detect dead connections with heartbeats. Send a ping every 30–60 seconds. If no pong comes back, assume the connection is half-open, close it, and reconnect.
- Reconnect with backoff and jitter, the same as message retries, and immediately when the OS reports the network is back.
-
Sync after every reconnect. Events sent while the socket was down are lost, so call
GET /v1/messages?afterMessageId=<last>to fill the gap. Then flush the outbox.
The socket delivers new events quickly, and the sync endpoint makes sure none are missing.
5. Presence and typing indicators
These are the noisiest events in the system, so throttle them on the client:
- Send
isTyping: trueat most once every 3 seconds while the user types, andfalsewhen they stop or send. - On the receiving side, hide the indicator if no update arrives within about 5 seconds. A lost
falseevent never leaves "typing..." on screen forever. - Subscribe to presence only for the open conversation, not the whole contact list.
6. The local schema
Three tables cover the scope:
CREATE TABLE users (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
avatar_url TEXT
);
CREATE TABLE conversations (
peer_id TEXT PRIMARY KEY REFERENCES users(user_id),
last_message_preview TEXT,
last_activity_at INTEGER NOT NULL,
unread_count INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE messages (
local_id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT UNIQUE, -- set for messages this phone sent
server_id INTEGER UNIQUE, -- NULL until the server confirms
peer_id TEXT NOT NULL,
is_outgoing INTEGER NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL, -- PENDING, SENT, DELIVERED, READ, FAILED
retry_count INTEGER NOT NULL DEFAULT 0,
local_created_at INTEGER NOT NULL
);
CREATE INDEX idx_messages_peer ON messages(peer_id, server_id);
The conversations table is denormalised on purpose. The chat list is the most-opened screen, and reading a preview and unread count from one row is far cheaper than scanning every message.
The UNIQUE constraint on server_id protects against duplicates. If the same message arrives over the socket and again in a sync, the second insert is ignored.
Bonus: push notifications
When the app isn't in the foreground, there's no socket. The server sends a push through Firebase Cloud Messaging (Android) or APNs (iOS). A push only signals that something new exists. It wakes the app or shows a banner, and the app then fetches the messages from the sync endpoint, because pushes can be delayed, collapsed, or dropped by power-saving modes.
Step 5: Wrap Up
In the last few minutes, summarise the decisions and be honest about what each one costs:
| Decision | Why | Trade-off |
|---|---|---|
| REST for sends, WebSocket for events | Clear errors and retries for sends; a small, focused socket | Two channels to maintain |
| Local database as source of truth | Offline history, one rendering path, no inconsistent screens | More sync and migration logic on the client |
Outbox with requestId and backoff |
No lost or duplicate messages | Pending messages need their own UI states |
| Server IDs for ordering, local time for pending | Instant feedback plus a consistent final order | A message can shift position once confirmed |
| Socket only in the foreground | Saves battery | Background delivery depends on push, which can be delayed |
Then name what you'd add with more time:
- Group chats: fan-out on the server, per-member receipts, and member roles
- Media: compress on the phone, upload to object storage first, then send a message that references the file
- End-to-end encryption: keys stored on the device, so the server only relays encrypted content
- Multiple devices: syncing history and read state between a phone and a desktop
- Encrypted local storage: SQLCipher or the platform's file protection for the message database
Don't call the design perfect. Each choice above has a cost, and naming it shows you understand the design.
Top comments (0)