Home › High-Level Design › Design WhatsApp / Messenger
D

Design WhatsApp / Messenger

A message leaves one phone and has to land on another in under a second, survive the recipient being offline, and hold up when a billion people chat at once. It looks like "store a row, read a row," and then every hard problem in distributed systems hides inside pushing state to clients you cannot poll. The candidates who pass figure out fast that almost nothing here is CRUD, and spend their 45 minutes on fan-out and delivery to devices they do not control.

High-Level DesignHard14 min read

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. Accept fast, deliver async. Ack the sender the instant the message is durably in the recipient's inbox, never when it is read. One write path, one delivery path, decoupled by design.
  2. Server is a mailbox, not an archive. Store per-recipient rows, deliver, then delete. That one constraint keeps storage near 720 GB despite tens of billions of messages a day.
  3. Hybrid group fan-out. Write fan-out for normal groups (copy into each inbox), read fan-out above a 1,000-member cap, so a broadcast blowup can never melt the cluster.
  4. Per-conversation sequence, not wall clocks. A monotonic counter per conversation gives total order with no distributed lock; ULIDs handle uniqueness and storage locality.
  5. At-least-once with idempotency on both ends. client_msg_id on send, server_msg_id on deliver, so every flaky-network retry is safe and never double-renders a bubble.

1Clarifying the requirements#

Pin down what "chat" means before drawing a single box. The scope swings wildly between a toy and a billion-user firehose, so treat these answers as load-bearing. Here is how I would open:

Candidate: Are we doing one-to-one only, or group chats too? And do we need media, or just text?

Interviewer: Both one-to-one and groups. Groups up to, say, 500 members. Text plus images and short video.

Candidate: Do we need delivery and read receipts? The double-tick behavior?

Interviewer: Yes, sent, delivered, and read. Those matter a lot to users.

Candidate: Last question on scope: end-to-end encryption, and does message history live on the server forever?

Interviewer: Assume E2E encryption is a goal but keep it light. And no, the server should not keep messages after they are delivered to all recipients. Treat the server as a relay with short-lived storage.

What those answers just told me:

  • The interview is about real-time delivery and fan-out, not storing chat.
  • "Relay with short-lived storage" is the whole personality of the design: we are not Slack, the server is a mailbox, not an archive.
  • Delete-on-delivery changes the storage math (gigabytes, not petabytes) and the consistency story (we never promise a searchable global history).
  • I want the interviewer to see I caught the implication, not just the answer.

Functional requirements

  • Send and receive one-to-one messages in near real time.
  • Group chat up to 500 members.
  • Delivery states: sent, delivered, read.
  • Media messages (images, video) up to a size cap.
  • Offline delivery: if you are offline, you get messages when you reconnect.
  • Presence: online / last seen.
  • Multi-device: the same account on phone plus desktop.

Non-functional requirements

  • Scale to 1B daily active users, tens of billions of messages a day.
  • Latency: p99 end-to-end delivery under 1 second for online users.
  • Availability 99.99%. People notice chat being down instantly.
  • Durability until delivered: a message the server accepted must not vanish before it reaches every recipient.
  • Ordering: messages in a conversation arrive in the order the sender sent them.
From the interviewer's chair

This opening is scored before you draw a single box. I am listening for whether you catch that "delete after delivery" is the thesis, not a detail. Start sketching message history tables and full-text search and I have quietly downgraded you. Name the mailbox stance first, then go spend your time on delivery.

2Back-of-the-envelope estimation#

Get the scale honest before committing to an architecture, because here one number decides everything. I will flag it when I reach it.

The point of this section

You are not here to compute storage to three decimals. You are here to surface one load-bearing number: delivery work is roughly 6x acceptance work, because a single message fans out to many recipients. That asymmetry is what justifies dumb gateways, async fan-out, and spending your design budget on push, not on accepting the write.

Users and messages.

  • 1B DAU, each sending about 40 messages a day, so 40B messages a day.
40B / 86,400 sec ~= 463K messages/sec written (average)
  • Chat is spiky. Evening peaks run 3x to 4x average.
  • Design for roughly 1.5M messages/sec at peak writes.

Reads vs writes (this is the one that matters).

  • A one-to-one message is read by one person. A 500-member group message is read by up to 499.
  • If about 10% of messages are group with ~50 real recipients each, the fan-out multiplier is:
reads = 0.9 * 1 + 0.1 * 50 = 0.9 + 5 = ~5.9 deliveries per message sent
  • Peak delivery rate lands around 9M messages/sec. Delivery, not acceptance, is 6x the work.
  • This tells me the expensive machinery is fan-out and push, and the cheap part is accepting the write.
~9M/sec
The number that drives the designPeak deliveries per second, 6x the write rate. It is why gateways stay dumb, fan-out runs async, and the group-size cap exists at all. Remember this one; every later decision traces back to it.

Connections.

  • A big chunk of users are connected at once. Say 300M devices hold a live socket at peak.
  • One box comfortably holds ~500K idle WebSocket connections.
300M / 500K = 600 gateway boxes (just for live sockets)

Storage (the number that justifies the stance).

  • Messages are short-lived (deleted after delivery), but undelivered ones queue up.
  • Assume 2% of messages are in flight for an offline recipient. Average payload with metadata ~300 bytes.
40B/day * 300 bytes = 12 TB/day flowing through
undelivered buffer at 2% held ~3 days = 40B * 0.02 * 300B * 3 ~= 720 GB
  • 720 GB is tiny, precisely because we delete on delivery. That number is the whole argument for "relay, not archive."
  • Keep history instead and you write 12 TB a day into cold storage forever: a completely different and more expensive product.

Media.

  • Say 5% of messages carry media averaging 300 KB.
40B * 0.05 * 300KB = 600 TB/day of media uploads
  • Media lives in object storage behind a CDN. It never touches the message pipeline except as a URL.
  • The "message" I move at 9M/sec is a 300-byte envelope with a pointer, not a photo.

3Core APIs#

Clients do not HTTP-poll for messages. The read path is a persistent connection. Setup, media, and group management stay plain requests.

POST /v1/messages
Authorization: Bearer <token>
Content-Type: application/json

{
  "client_msg_id": "c0ffee-8821",
  "conversation_id": "conv_9f3",
  "type": "text",
  "ciphertext": "base64...",
  "media_url": null
}

200 OK
{ "server_msg_id": "01H...", "ts": 1730600000123, "state": "sent" }

Two design choices worth defending:

  • client_msg_id is the idempotency key, and it does real work. A flaky-mobile retry dedupes on it and returns the same server_msg_id, so a spotty subway ride never produces duplicate bubbles.
  • The POST returns the moment the message is durably in the inbox, before it reaches the recipient.
POST /v1/media/upload-url
{ "mime": "image/jpeg", "size": 240000 }

200 OK
{ "upload_url": "https://blob...", "media_url": "https://cdn..." }

POST /v1/receipts
{ "server_msg_id": "01H...", "state": "read" }

GET  /v1/presence?user_ids=u1,u2,u3

The media flow is a two-step:

  • Client asks for a signed upload URL.
  • Client uploads the bytes straight to blob storage, never through our servers.
  • Client sends a normal text-type message whose media_url points at the CDN.
  • That keeps 600 TB/day of photos off the hot path.

Over the live socket, the server pushes frames like:

{ "op": "deliver", "server_msg_id": "01H...", "conversation_id": "conv_9f3", "from": "u7", "ciphertext": "..." }
{ "op": "receipt", "server_msg_id": "01H...", "state": "delivered" }

4High-level design#

The point of this section

The box diagram is table stakes. What earns points is the single seam: the gateway owns sockets and nothing else, and the inbox queue sits between accept and deliver. That seam lets a post ack instantly, lets you lose a gateway without losing a message, and absorbs the offline case for free. Draw the boxes fast, then defend that seam.

The spine is standard, so draw it fast:

  • A phone opens one long-lived WebSocket to a Connection Gateway.
  • The gateway owns sockets and knows which user sits on which box. Nothing else.
  • A Message Service assigns an ID and sequence, drops the message in the recipient's inbox, and asks a Router where that recipient is connected.
  • The router pushes the frame to the right gateway, which writes it down the socket.
flowchart LR P[Phone] -->|WebSocket| G[Connection Gateway] G --> MS[Message Service] MS --> SEQ[Sequencer] MS --> IQ[(Inbox Queue)] MS --> R[Router] R --> RT[(Routing Table Redis)] R --> G2[Gateway of recipient] G2 -->|WebSocket| P2[Phone of recipient] P --> MU[Media Service] MU --> BLOB[(Object Storage)] BLOB --> CDN[CDN] MS --> PN[Push Notifier] PN --> APNS[APNs FCM]
The whole system at a glance. The seam that matters is the dumb gateway plus the inbox queue between accept and deliver: it lets a send ack instantly, survives a gateway dying, and handles the offline recipient without special-casing.

The key decisions baked into this picture.

  • The gateway is dumb and stateless about content. It holds sockets and forwards. Scale by adding boxes for more of the 300M sockets, and lose one without losing a single message, because the message was never the gateway's to keep.
  • The routing table in Redis maps user_id -> gateway_id (and device). It is the phone book: the gateway writes the mapping on connect, clears it on disconnect. Every one of those 9M deliveries/sec does at least one lookup here, so it gets its own deep dive.
  • The inbox queue is per-user durable storage of undelivered messages. Offline recipient? The message sits here until they come back and drain it. This is the mailbox.
  • The Push Notifier wakes sleeping phones through Apple's APNs and Google's FCM so the app reconnects and pulls. We cannot hold a live socket to a phone the OS put to sleep, so this is how offline delivery actually gets kicked off.

The flow in one line.

  • Accept fast, persist to the recipient's inbox, then try to deliver live.
  • Live delivery fails because the recipient is offline? The message is already safe in the inbox, so we fire a mobile push.
  • I never block the sender on the recipient being reachable.

5Data model#

Use a few stores, each shaped around exactly one query, so a single partition read answers it. Writes are relentless at 1.5M/sec, so the hot stuff goes in a wide-column store (Cassandra-style). No joins, ever.

Messages (short-lived, per conversation):

Field Type Notes
conversation_id string Partition key
seq bigint Clustering key, per-conversation monotonic order
server_msg_id ULID Globally unique, time-sortable
sender_id string Who sent it
type enum text, image, video, system
ciphertext blob Encrypted payload, server cannot read
media_url string Null for text
created_at timestamp Server receive time
ttl int Row expires after delivery window
  • Partitioning by conversation_id makes "give me this conversation in order" a single-partition range scan on seq.

Inbox / per-recipient delivery queue:

Field Type Notes
user_id string Partition key
device_id string Multi-device: one row per device
server_msg_id ULID Clustering key
conversation_id string For client grouping
state enum queued, delivered, read
enqueued_at timestamp For retry and cleanup
  • Keyed per device, not per user, because "delivered" means every one of your devices pulled it. This row is the unit the drain-and-reap logic works on.

Conversations:

Field Type Notes
conversation_id string Primary key
type enum direct or group
member_ids set For groups, the membership list
last_seq bigint Current high-water sequence
created_at timestamp

Presence (Redis, not the DB):

Field Type Notes
user_id string Key
status enum online, offline
last_seen timestamp Updated on heartbeat
gateway_id string Which box holds the socket
  • Presence changes thousands of times per user per day and nobody needs it durable, so a persistent write per heartbeat would be waste.
  • A short TTL does the cleanup: a missed heartbeat lets the key expire and the user flips to offline on its own. No janitor process sweeping a billion rows.

6Deep dives#

6.1 Delivery, receipts, and offline#

This is the core loop and the signature hard part. Walk the full path for one message, online and offline.

sequenceDiagram participant A as Sender phone participant GA as Gateway A participant MS as Message Service participant IQ as Inbox store participant RT as Routing table participant GB as Gateway B participant B as Recipient phone A->>GA: send message with client_msg_id GA->>MS: forward MS->>MS: dedupe assign seq and server_msg_id MS->>IQ: persist to recipient inbox MS-->>A: ack state sent MS->>RT: lookup recipient gateway RT-->>MS: Gateway B MS->>GB: push deliver frame GB->>B: deliver over socket B->>GB: ack delivered GB->>MS: receipt delivered MS-->>A: receipt delivered
The one-to-one delivery path. The sender is acked the instant the message is durably in the inbox (first tick), and the delivered receipt (second tick) comes back only after Gateway B confirms the socket write and the phone acks.

Why the ack lands where it does.

  • The sender gets "sent" the moment the message is durably in the inbox, not when the recipient sees it.
  • That gives a fast single tick and decouples the sender's latency from whether the recipient's phone is even on.
  • The delivered receipt (second tick) waits for Gateway B to confirm the socket write and the phone to ack.
  • The read receipt is a separate client-initiated event, fired when the chat is actually on screen, which is why read can lag delivered by minutes.

When the recipient is offline.

  • The routing lookup returns nothing, so we stop and the message stays queued in the inbox.
  • The Push Notifier sends a quiet push to APNs or FCM.
  • The phone reconnects, the gateway tells the Message Service "user B device D is back," and we drain their inbox: deliver everything with state = queued in server_msg_id order, then flip rows to delivered.
  • Because server_msg_id is a ULID, that drain is already in roughly the right time order for free.
Pause before you read on: we push a message, the socket dies before the ack, and the phone reconnects. What stops the user from seeing the same bubble twice?
Reveal the answer
The client dedupes on server_msg_id, and the sender side dedupes on client_msg_id. Delivery is at-least-once on purpose, so the pipeline can redeliver on reconnect as aggressively as it likes. Both ends are idempotent, which means a retry never double-renders. I would rather deliver twice and dedupe than risk delivering zero times, because a lost message is the one failure users never forgive.

At-least-once means dedupe on the client too.

  • We may push, lose the socket before the ack, and redeliver on reconnect.
  • Client dedupes on server_msg_id, send side dedupes on client_msg_id.
  • Both ends idempotent, so retries are safe and never create a duplicate bubble.

6.2 Group fan-out#

A 500-member group is where naive designs fall over, and it is the direct consequence of that 9M/sec number. The question is where fan-out happens.

  • Write fan-out: on post, the Message Service writes one copy into each of the 499 recipients' inboxes immediately. Delivery is then a normal per-user drain. Reads cheap, writes heavy.
  • Read fan-out: store the message once per conversation and let each member pull it, tracking per-member read cursors. Writes cheap, reads do the work.
Default for normal groups
Write fan-out
Read cost
Trivial: drain one precomputed inbox.
Write cost
N inbox writes per message, one per member.
Best when
Delivery latency is king and groups are small.
Breaks when
A broadcast group hits a million members: one post becomes a million writes.
Keep for the giants
Read fan-out
Read cost
Heavy: each member pulls against a shared cursor.
Write cost
Trivial: one stored copy per conversation.
Best when
Membership is huge or the channel is broadcast-style.
Breaks when
Small active groups pay read overhead for no reason.

For chat, delivery latency wins almost everywhere, so write fan-out is my default:

  • 499 inbox writes at 300 bytes each is roughly 150 KB of writes, which is nothing.
  • But the math inverts at the extreme: write fan-out to a million inboxes on every message would melt the cluster.
  • So the rule I draw on the board: groups under ~1,000 members get write fan-out, above that read fan-out with a shared cursor.
flowchart TD M[Group message in] --> C{Members over 1000} C -->|No| WF[Write fan-out] WF --> I1[(Inbox member 1)] WF --> I2[(Inbox member 2)] WF --> IN[(Inbox member N)] C -->|Yes| RF[Read fan-out] RF --> S[(Single conversation log)] S --> CUR[Per member read cursor]
The group fan-out fork. Under the 1,000-member cap, copy into each inbox for cheap per-user delivery; above it, store once and let members pull against a shared cursor so a broadcast cannot explode into millions of writes.

Fan-out runs async through a queue.

  • The sender's ack does not wait on 499 writes.
  • The sender gets "sent" as soon as the message is persisted to the conversation log and enqueued for fan-out.
  • Members get it a few tens of milliseconds later.
  • Same "accept fast, deliver async" move from the one-to-one path, just multiplied, which is why the sender never feels the size of the group.

6.3 Ordering#

Users will scream if message B shows above message A when A was sent first. I do not rely on wall-clock timestamps across machines, because clocks drift and a sender and recipient on different continents will disagree on "when." Ordering is per conversation, the only scope a user ever perceives.

How the sequence works.

  • The Sequencer owns a monotonic counter per conversation_id. Every accepted message gets the next seq.
  • A single conversation's writes route to one sequencer shard (hash on conversation_id), so the counter is strictly increasing with no cross-machine coordination.
  • That gives a total order within a conversation without a distributed lock.
  • Clients render by seq, not by received time.

Out-of-order arrival is normal and handled.

  • A client receiving seq 7 before seq 5 buffers 7 and waits for 5, or asks the server for the gap.
  • The user never sees the reorder.

Two IDs doing two jobs, deliberately kept separate.

  • seq is the authoritative in-conversation order: it is for correctness of ordering.
  • ULID server_msg_id is for global uniqueness, dedupe, and storage locality.
  • Mixing those two jobs into one field is a common mistake. Say the split out loud.

6.4 Connection management and presence#

Each gateway holds hundreds of thousands of sockets, up to that 500K figure. Presence is just "do I have a live socket for this user, and is the heartbeat fresh."

Heartbeats drive presence, TTL does the cleanup.

  • The phone sends a ping every ~20 seconds.
  • The gateway refreshes the Redis presence key with a 40-second TTL.
  • Miss two pings and the key expires: the user is offline automatically, with no janitor sweeping a billion rows.

Do not broadcast presence to the whole graph.

  • Showing "online" to a friend means subscribing to presence changes, and this is where people accidentally design an N-squared firehose.
  • A user with 2,000 contacts going online is not 2,000 push events.
  • Presence is pulled lazily: open a chat, the client asks for that one person's presence and subscribes just to them.
  • We push presence updates only to the small set of users actively looking at that contact's chat right now.
  • That keeps presence traffic proportional to open chat windows (a handful per user), not to the social graph (thousands per user).
Red flag that fails you here

Fanning presence out to every contact on every online flip. That single choice is the difference between presence being a rounding error and presence being its own 9M/sec problem. Candidates who broadcast it to the graph have not thought about who is actually watching.

7Bottlenecks and trade-offs#

The gateway fleet is the scary part.

  • 600+ boxes, each holding 500K sockets. One box dies and 500K phones reconnect at once, tending to hit the same load balancer in the same second.
  • That thundering herd can cascade: the reconnect spike knocks over the next box, which dumps another 500K.
  • Defense: jittered reconnect backoff on the client (reconnect after a random 1 to 30 seconds, not instantly).
  • Defense: spread gateways across many LB targets so the reconnect wave diffuses instead of landing on one door.

The routing table is a hot dependency.

  • Every message does at least one Redis lookup to find the recipient's gateway. At 9M deliveries/sec that is at least 9M reads/sec hitting Redis.
  • Shard Redis by user_id and cache the mapping on the Message Service for a few seconds.
  • A just-moved user might get one failed push and a retry, which is far cheaper than keeping Redis perfectly fresh at 9M/sec.
  • This is deliberately loose consistency chosen on purpose, and I want the interviewer to see the choice.

Hot conversations.

  • A celebrity group or viral broadcast concentrates writes on one conversation_id shard, which is also where the sequencer counter lives, so it is a hot key on write and ordering at once.
  • Mitigation: detect high-rate conversations and switch them to read fan-out (as in 6.2).
  • Mitigation: relax strict sequencing for broadcast-style channels where exact ordering matters less than throughput.
  • For a one-way announcement channel, nobody cares if two posts a millisecond apart swap, so spend that strictness budget elsewhere.

Delete-on-delivery vs multi-device.

  • The requirement says delete after delivery, but multi-device breaks the simple version: a message is "delivered" only when all of a user's devices have pulled it.
  • So the inbox is keyed per device, and a row is reaped only when every active device acks.
  • A user with a dead second phone they never open would pin messages in the mailbox forever, quietly breaking the 720 GB estimate.
  • So I add a hard TTL (say 30 days) regardless. That is a deliberate trade: lose an undelivered message to a long-dead device rather than grow the mailbox without bound.
  • I say that trade-off out loud, because pretending the tension does not exist is worse than naming it.

E2E encryption limits the server.

  • ciphertext is opaque, so the server cannot do server-side search, content spam filtering, or smart grouping. It routes bytes.
  • Search and rendering move to the client, which holds the keys.
  • Spam defense moves to metadata signals (send rate, graph patterns) instead of content, which is weaker.
  • That is a real cost of the privacy stance, and I flag it as a known gap rather than claim we solved it.

Consistency model.

  • This is an AP system in CAP terms. During a partition I keep accepting and delivering, and I tolerate temporary disagreement (you might see "sent" while the other side already read it, for a second).
  • I will not block a send waiting for global agreement, because a chat app that stalls on partition is a dead chat app.
  • The one firm guarantee: once the server acks a message, it will eventually reach every recipient device or expire by TTL, and within a conversation order is preserved.
  • Everything else I am willing to let be eventually consistent.

This problem is not about storing chat, it is about pushing it. The 9M deliveries/sec number is the tell.

8Summary#

The decisions that matter:

  • Persistent WebSocket per device through a dumb, stateless Connection Gateway. Scale by adding boxes, survive failures because content lives elsewhere.
  • Server as a mailbox, not an archive. Per-recipient inbox rows, deliver, then delete. Keeps storage near 720 GB despite tens of billions of messages a day.
  • Accept fast, deliver async. Ack the sender when the message is durably queued, not when it is read. Fan-out and delivery happen behind the ack.
  • Write fan-out for normal groups, read fan-out above a ~1,000-member cap. Optimize delivery latency where it matters, protect against broadcast blowups where it does not.
  • Per-conversation sequence numbers for ordering, not wall clocks. ULIDs for global uniqueness and dedupe, kept as a separate job from seq.
  • At-least-once with idempotency keys on both ends (client_msg_id and server_msg_id) so retries are safe and never double-render.
  • Presence in Redis with TTL heartbeats, pulled lazily per open chat rather than broadcast to the graph.
  • AP under partition. Availability and low latency over strict global consistency, with the one firm promise that an acked message eventually reaches every device or expires.
Follow-ups the interviewer will ask (and your answers)
"A phone retries a send on a flaky network. Do I get two messages?"
No. The POST carries a client_msg_id idempotency key. The Message Service dedupes on it and returns the same server_msg_id instead of creating a second row. On the delivery side, the client dedupes on server_msg_id. Both ends are idempotent, so retries are safe by construction.
"A 500,000-member broadcast group posts every few seconds. What happens?"
Pure write fan-out would turn one post into half a million inbox writes and melt the cluster, so above a ~1,000-member cap I switch to read fan-out: one stored copy per conversation and members pull against a shared cursor. For broadcast channels I also relax strict sequencing, since exact ordering matters less than throughput there.
"Two phones on different continents disagree on message time. How do you order?"
I never trust wall clocks across machines. A Sequencer owns a monotonic counter per conversation_id, routed to one shard by hashing the conversation id, so every message gets a strictly increasing seq with no distributed lock. Clients render by seq, buffering any out-of-order arrival until the gap fills. ULIDs handle uniqueness and storage, not ordering.
"A user has a dead second phone they never open. Does the mailbox grow forever?"
It would, because delivered means all registered devices pulled it, so the inbox is keyed per device and a row is reaped only when every active device acks. To bound it, I add a hard TTL (say 30 days). That is a deliberate trade: lose an undelivered message to a long-dead device rather than let the 720 GB mailbox grow without limit.
"A gateway box dies and 500K sockets reconnect at once. How do you survive it?"
That is a thundering herd that can cascade across boxes. I use jittered client backoff (reconnect after a random 1 to 30 seconds, not instantly) and spread gateways across many load-balancer targets so the reconnect wave diffuses. No message is lost, because the gateway holds no content: everything in flight is safe in the inbox.

What you can now explain in the room

  • Why delivery is 6x the write rate, and how the 9M/sec number drives every decision.
  • Why the server is a mailbox not an archive, and how delete-on-delivery keeps storage near 720 GB.
  • Why you ack the sender at durable-queue time, not at read time, and how receipts flow.
  • When to use write fan-out versus read fan-out, and where the 1,000-member cap comes from.
  • Why ordering uses a per-conversation sequence instead of wall clocks, and why ULID is a separate job.
  • How presence stays a rounding error by pulling lazily per open chat instead of broadcasting to the graph.

For how specific companies probe this one, see our related guides on the engineering interviews at major social and infra companies.

Get a prep plan built around your Design WhatsApp / Messenger interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design WhatsApp / Messenger prep plan is on the way.