DEV Community

Cover image for Design WhatsApp: A Mobile System Design Walkthrough
Shakibaenur
Shakibaenur

Posted on

Design WhatsApp: A Mobile System Design Walkthrough

"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:

  1. Clarify the requirements
  2. Design the API and data model
  3. Sketch the client architecture
  4. Deep dive into the hard problems
  5. 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" }
Enter fullscreen mode Exit fullscreen mode

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 } }
Enter fullscreen mode Exit fullscreen mode

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 messageId and the timestamp. Phone clocks can be wrong or changed by the user, so the server is the authority on order.
  • Sync is cursor-based. afterMessageId asks 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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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:

  1. The user taps send. A row is inserted with status PENDING and a new requestId. The bubble appears instantly.
  2. The background SendScheduler (WorkManager on Android, BGTaskScheduler plus a URLSession background task on iOS) picks up pending rows in order.
  3. It sends POST /v1/messages with the same requestId on every attempt.
  4. On 201 Created, one transaction stores the server's messageId and sets the status to SENT.
  5. 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.
  6. After too many attempts, the row becomes FAILED and 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)
}
Enter fullscreen mode Exit fullscreen mode

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 DELIVERED receipt.
  • When the conversation is on screen and the message is visible, it sends a READ receipt.

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: true at most once every 3 seconds while the user types, and false when they stop or send.
  • On the receiving side, hide the indicator if no update arrives within about 5 seconds. A lost false event 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);
Enter fullscreen mode Exit fullscreen mode

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)