Engineering Atlas › System Design Interviews

Design a chat messaging app

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope: calls (see the WebRTC lesson), payments, Status, Channels, spam detection and cryptographic proofs.

Back-of-envelope

Round assumed numbers, not Meta's published figures.

API

The client speaks a binary protocol over one persistent connection. WhatsApp wraps it in Noise Pipes (Curve25519, AES-GCM, SHA-256) rather than TLS, because it gives a fast, lightweight connection setup and resume (WhatsApp Encryption Overview). Media goes over HTTPS. The frames below are shown as JSON for readability.

-> hello      { device_id, auth_token, last_acked_offset: 81231 }
<- welcome    { pending: 14 }

-> send       { client_msg_id, conv_id, copies: [ { device_id, ciphertext } ... ] }
<- accepted   { client_msg_id, conv_id, seq: 5512, server_ts }        (one tick)

<- deliver    { offset: 81232, conv_id, seq: 5512, from_device, ciphertext }
-> ack        { upto_offset: 81245 }                                   (server deletes)

-> receipt    { conv_id, to_user, kind: "delivered" | "read", upto_seq: 5512 }
<- receipt    { conv_id, from_user, kind, upto_seq }                   (two ticks / blue)

GET  /v1/keys/{user_id}            -> { devices: [ { device_id, identity_key, signed_prekey, one_time_prekey } ] }
POST /v1/media (resumable upload)  -> { media_id, url }  ciphertext only; key travels inside the message

The sender encrypts once per recipient device, so send carries a list of copies. client_msg_id makes a retried send idempotent. Receipts are cumulative (upto_seq), so opening a chat with 40 unread messages sends one read receipt, not 40.

Data model

mailbox is partitioned by device_id, primary key (device_id, offset). Columns: conv_id, seq, from_device, ciphertext, enqueued_at. offset is a per-device counter. It is a queue: inserts at the tail, range reads after the last ack, deletes at the head. Partitioning by device makes catch-up a single range scan on one shard.

conversations is partitioned by conv_id. Columns: kind (direct or group), head_seq, membership_version. head_seq assigns a per-conversation sequence number on accept, giving receivers a stable order and making cumulative receipts possible.

group_members is partitioned by group_id: (group_id, user_id) -> role, joined_at. Groups are small, so one partition read returns the whole roster. Every membership change bumps membership_version, and senders must encrypt for the current version or the server rejects the send.

devices (the key directory) is partitioned by user_id: (user_id, device_id) -> identity_key, signed_prekey, prekey_count, last_seen. A second table holds one-time prekeys, each handed out once and then deleted.

dedupe holds (conv_id, sender_device, client_msg_id) -> seq with a TTL of a few days, long enough to absorb any client retry.

media lives in object storage keyed by a random media_id, holding only ciphertext.

High-level design

flowchart LR
  A["Sender device"] <-->|"persistent socket"| E1["Edge / chat server"]
  E1 --> R["Router (session registry)"]
  R --> M[("Mailbox shards by device_id")]
  R -->|"device online"| E2["Edge holding recipient socket"]
  E2 <--> B["Recipient devices"]
  R -->|"device offline"| P["Push gateway (APNs / FCM)"]
  A --> K["Key directory"]
  A --> O[("Media blob store + CDN")]
  B --> O

Write path.

  1. The sender's app gets the recipient's device list and prekeys from the key directory (cached locally), encrypts once per recipient device and per its own other devices, and sends one send frame with all copies.
  2. The edge server checks the dedupe table; a retried client_msg_id gets the original seq back.
  3. The router increments head_seq for the conversation and appends each copy to the recipient device's mailbox. Once every append is durable it replies accepted (one tick).
  4. For each recipient device with a live socket in the session registry, the router forwards the envelope to that edge server. Otherwise it asks the push gateway for a notification. On iOS this cannot be a silent background push: Apple treats those as low priority, may throttle them, and says to send no more than two or three per hour (Apple). Messaging apps send a visible alert carrying the ciphertext and decrypt it in a notification service extension, which has about 30 seconds to rewrite the payload (Apple).

Read path.

  1. A device connects and sends hello with the last offset it acknowledged.
  2. The edge server streams mailbox entries after that offset, in batches.
  3. The device decrypts, stores locally, and sends ack upto_offset. The server deletes those rows and emits a delivery receipt to each sender.
  4. When the user opens a chat, the app sends one cumulative read receipt per sender.

Deep dives

1. The offline inbox: queue, not log

Option one is the Design Slack model: a per-conversation log kept forever, synced by cursor. Under end-to-end encryption that history is useless to the server (no search, no previews), and every device must poll every conversation. Option two is a per-device mailbox that deletes on ack. I choose the mailbox: a reconnect costs one range scan proportional to what this device missed, and storage shrinks to the in-flight set.

The subtle part is the ack. The device must ack only after the message is durable in its own local database, never on receipt of the frame. Acks are cumulative offsets so a device that received 500 messages sends one ack. A device that disconnects before acking gets the same entries again and drops them by (conv_id, seq): at-least-once on the wire, exactly-once on screen. A sweeper expires entries after about 30 days, which matches what WhatsApp documents: undelivered messages are kept encrypted for up to 30 days, and delivered ones are deleted from its servers (WhatsApp privacy policy).

2. Multi-device fan-out under end-to-end encryption

Options considered: give all of a user's devices the same key (simple, but one stolen laptop reads everything forever and you cannot revoke a device), make the phone the only real device and relay through it (WhatsApp's original web client worked this way and failed whenever the phone's battery died), or give every device its own identity and encrypt per device. Meta's 2021 multi-device design chose the last option: each device has its own identity key, the sender encrypts once per device ("client-fanout"), and a new companion gets recent history as an encrypted bundle from the phone rather than from the server (Meta). I choose the same; one encryption per device costs microseconds.

For groups, encrypting a 1 KB message 2,000 times would be slow on a cheap phone. The Signal Protocol's sender keys solve this: each member distributes one symmetric sender key to the group once over pairwise sessions, then encrypts each message once and uploads a single ciphertext, and the server does the fan-out (WhatsApp Encryption Overview). So for groups the send frame carries one body, not one copy per device. The server still writes one mailbox entry per device, but those entries share that body, so the router stores it once and references it. When a member leaves, every remaining member clears its sender key and starts over, which is why membership changes are expensive and why the server enforces membership_version on sends.

3. Receipts, presence and the chatter around messages

Receipts are themselves tiny encrypted messages routed back to the sender's devices, so they reuse the mailbox and inherit its delivery guarantee. In a group, the sender's app aggregates them: a message shows two ticks only when every recipient has at least one device that acked, and blue ticks only when every recipient has read it. Cumulative upto_seq receipts and batching (one receipt per chat per second, not per message) cut receipt traffic by an order of magnitude.

Presence and typing indicators are the opposite: ephemeral, never stored, sent only to subscribers online right now, and rate-limited. Losing one costs nothing, so the router sheds them first under overload.

Down the stack

Layer 1The contract

  • Accept. When the sender gets accepted with a seq, every recipient device's copy is durable on at least two mailbox replicas. A retry with the same client_msg_id returns the same seq.
  • Deliver. Each copy reaches its device at least once while the device reconnects within the retention window. Copies for one device are handed out in offset order. The device deduplicates by (conv_id, seq).
  • Ack. ack upto_offset = n deletes every entry with offset <= n for that device and is idempotent.
  • Receipts. Status per message only moves forward: sent, then delivered, then read. A read receipt implies delivered.

Layer 2The mechanism

WhatsApp built its server on Erlang running on FreeBSD, with a protocol derived from XMPP and compressed into short binary tokens. Each connected device is one lightweight Erlang process holding the socket; the session registry maps device_id to that process, and sending to a device is an Erlang message to it. Offline storage used Mnesia, Erlang's replicated database. In 2014 WhatsApp reported that about half of messages are read within 60 seconds, and a write-back cache added in front of offline storage, so messages could be delivered before being written to disk, had a 98% hit rate (High Scalability).

Encryption follows the Signal Protocol. X3DH sets up a pairwise session from a published identity key, signed prekey and one-time prekey, so a sender can encrypt to an offline device. The Double Ratchet then derives a fresh key per message for forward secrecy. Note the drift: Signal itself replaced X3DH with PQXDH in 2023, adding a post-quantum KEM to session setup (Signal PQXDH), while WhatsApp's February 2026 overview still describes classical Curve25519 prekeys.

Layer 3The machine

  • Memory per connection. A newly spawned Erlang process uses 327 words, about 2.6 KB on a 64-bit VM, including its heap and stack (Erlang efficiency guide). Add kernel socket buffers (a few KB when idle, tuned down from defaults) and TLS or Noise session state, and an idle device costs roughly 10 to 20 KB. WhatsApp engineers reported over 2 million connections on one server in January 2012 (WhatsApp blog), which works out to tens of GB of RAM, well within a large host.
  • Readiness. The VM uses kqueue on FreeBSD (epoll on Linux), so one scheduler thread per core waits on millions of sockets.
  • Frame cost. A 300-byte envelope plus TLS record overhead fits in one TCP segment (MSS about 1,448 bytes), so delivery to an online device is one packet. On the phone, waking a cellular radio costs seconds of high-power state, so acks and receipts are batched and keepalives are minutes apart.
  • Mailbox writes. Append-then-delete within seconds suits a replicated in-memory table with a write-ahead log, or an LSM store where most entries die in the memtable.

Why it's built this way

  1. Phones are offline often and unpredictably, so the server must hold messages for them; holding them per device makes catch-up one range scan.
  2. End-to-end encryption means the server cannot use history for anything, so deleting on ack is both cheaper and safer.
  3. Deleting on ack requires a precise ack, so acks are cumulative offsets sent only after the client has persisted the message.
  4. Idle sockets dominate cost, so the runtime must make an idle connection nearly free.
  5. Groups are capped near a thousand, so per-device fan-out on write stays bounded and the read path stays a single queue.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Per-device mailbox, delete on ackTiny server storage, catch-up is one range scanNo server-side history or searchEnd-to-end encrypted messaging
Per-conversation log kept foreverSearch, history on new devices, simple syncPetabytes of storage, server sees contentWorkplace chat (Slack)
Per-device keysRevocable, phone can be offEncrypt per device, key directory to runMulti-device with E2EE
Phone as relay for other devicesOne identity, simple serverFails when the phone is offEarly web clients only
Pairwise encryption in groupsSimplest, strongest isolationCost grows with group sizeGroups of a few people
Sender keysOne encryption per messageRe-key on every membership changeGroups up to about a thousand
MLS (RFC 9420)Efficient re-keying for large groupsNewer, more complex stateVery large or frequently changing groups

Leadership lens

Check yourself

  1. Why must a device ack a message only after it is durable in its local database, and what happens if it disconnects before acking?

    Show answer

    The server deletes mailbox entries once acked, so an ack sent on receipt of the frame could lose a message the phone never saved. A device that disconnects before acking gets the same entries again and drops duplicates by (conv_id, seq), giving at-least-once on the wire and exactly-once on screen.

  2. Why does the design use a per-device mailbox that deletes on ack instead of a per-conversation log kept forever?

    Show answer

    Under end-to-end encryption the server cannot use history for search or previews, so keeping it costs petabytes for nothing. A mailbox makes reconnect one range scan proportional to what the device missed and shrinks storage to the in-flight set, at the cost of no server-side history.

  3. With 1.5B DAU and 40% connected at any moment, how many edge hosts does the design plan for, and why does memory per idle connection matter so much?

    Show answer

    That is 600M sockets, or 600 hosts at 1M sockets each, planned as 1,200 so each runs at half capacity and can absorb a neighbour's failover. Connections, not messages, size the fleet, so an idle device costing roughly 10 to 20 KB decides how many servers you buy.

  4. Why do groups use sender keys rather than pairwise encryption, and what does that choice make expensive?

    Show answer

    Encrypting a message once per member device would be slow on a cheap phone for a large group, while sender keys let each member encrypt each message once. When a member leaves, every remaining member must rotate its sender key, so membership changes are expensive and the server enforces membership_version on sends.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →