Blueprint
Realtime & messaging intermediate

Design WhatsApp

Two hundred million open sockets, guaranteed delivery to phones that are offline half the day, and a message bus that is allowed to drop things — because the inbox isn't.

asked at Meta · Microsoft · Uber Frequently asked ~40 min walkthrough
01

Scope the requirements

The interviewer wants two things found fast: messages must survive recipients being offline, and the connection count is so large that anything per-connection-per-heartbeat is instantly fatal. Everything else is decoration around those two facts.

Functional — what it must do

  • 1:1 and group chats (cap groups at 100 participants — say the cap out loud).
  • Send and receive messages in real time, with delivery and read acknowledgements.
  • Offline delivery: undelivered messages are stored (30 days) until the recipient reconnects.
  • Media attachments (photos, video) in messages.
  • Multi-device: one account, up to ~3 clients, all in sync.
  • Explicitly cut: stories, calls, payments, server-side search — offer them back as extensions.

Non-functional — the numbers that shape the design

  • Delivery latency under 500 ms to online recipients.
  • At-least-once delivery — a message accepted by the server must eventually reach every recipient device.
  • Scale: billions registered, ~200M concurrent connections.
  • Minimal server-side retention — delete on delivery, 30-day cap for the undelivered (privacy is a feature here).
  • Survive chat-server crashes and flaky mobile networks without losing messages.
Say this out loud

The hard requirement is guaranteed delivery to devices that are offline half the day, at 200 million concurrent connections. I'll design the durability story first and treat the realtime path as an optimisation layered on top of it.

02

Back-of-envelope estimates

The numbers here don't argue for sharding storage — messages are tiny and short-lived. They argue about connections: the heartbeat arithmetic alone rules out entire classes of design.

QuantityEstimateHow you got there
Concurrent connections~200M1B daily users, ~20% connected at any moment.
Messages~40k/s1B users × 20 msgs/day ÷ 86,400 s; ~100k row writes/s once group messages fan out into per-recipient inbox entries.
Heartbeats~20M/s200M connections ÷ 10 s interval. This is why heartbeats must never touch a database.
Chat servers~200–1,000a tuned box holds hundreds of thousands of WebSocket connections; the fleet is small, the routing between boxes is the problem.
Message storage~25 TB rolling4B msgs/day × ~200 B × 30-day retention; deleted on delivery, so the working set stays bounded.
Medianot through uspresigned upload straight to S3; the message carries a URL, never bytes.
Say this out loud

Forty thousand messages a second is ordinary database traffic — the scary number is twenty million heartbeats a second. So state lives in memory on the chat servers, and the database only ever sees messages, never liveness.

03

Sketch the API

Chat is bidirectional, so the API is WebSocket messages, not REST — the server initiates as often as the client does. HTTP survives only for login and media presigning.

WS C→SsendMessage{ chatId, message, attachments? } → { messageId }. The server's response is the durability acknowledgement.
WS S→CnewMessage{ chatId, senderId, message, seq }. The client MUST reply with an ack — that ack is what deletes the inbox entry.
WS C→ScreateChat / modifyParticipants{ participants } → { chatId }. Group membership changes flow over the same socket.
POST/api/v1/attachments→ presigned S3 URL. Client uploads media directly; the message carries the resulting key.
GET/api/v1/inbox?since={seq}Reconnect sync: everything undelivered since the client's last acked sequence number.
  • The client ack is load-bearing: newMessage delivery over the socket proves nothing — the network can drop the frame after the server sends it. Only the explicit ack lets the server delete the inbox row. No ack protocol, no delivery guarantee — this is the single most common structural omission in this question.
  • WebSocket over long polling or SSE, and I'd say why: chat is genuinely bidirectional at high frequency, which is the one workload where WebSocket's stateful-connection cost is clearly paid back.
04

Data model

Five small tables, all keyed for exactly one access pattern each. The interesting one is the inbox — it's the durability mechanism, not a mailbox feature.

messages
messageId PK (time-ordered) · chatId · senderId · content · TTL 30d
Written before anything else happens. TTL enforces the retention promise for the undelivered.
inbox
clientId PK · messageId SK
One row per undelivered message per device, not per user. Deleted on client ack — the table's steady-state size is 'what's currently in flight'.
chat_participants
chatId PK · userId SK (+ reversed GSI)
Both directions needed: 'who's in this chat' on send, 'which chats am I in' on login.
clients / last_seen
userId · clientId (~3 cap) · lastSeen ts
last_seen written on disconnect only — never per heartbeat.
The database call

I'd use DynamoDB (or Cassandra) — every access is a key-value lookup or a partition scan, writes are 100k/s and spiky, and native TTL does the 30-day cleanup for free. There's no relational query anywhere in this design, so Postgres buys me nothing here. If the product grew server-side message search or complex chat administration, that's when I'd bring in a relational or search store beside it — for the new workload, not as a migration.

05

High-level design

Sender and recipient usually sit on different chat servers, so the design is really about the hop between them: a durable write first, then a fast, lossy pub/sub hop that is allowed to fail because the inbox has it covered.

client edge service data realtime Sender device Recipient device may be offline Load balancer L4, sticky Chat server A holds sender socket Chat server B holds recipient socket DynamoDB messages + inbox S3 media, presigned Redis Pub/Sub channel per userId msg + inbox push WebSocket WebSocket publish ack: delete presign media
notice the order on the send path: durable write to messages + inbox first, then the dashed pub/sub hop. the bus can drop the message and nothing is lost — the recipient's next inbox sync catches it.
  1. Send: sender's device fires sendMessage over its socket → chat server A writes the message and one inbox row per recipient device to DynamoDB, then acks the sender. Durability is done before anyone hears about the message.
  2. Deliver: chat server A publishes to the Redis Pub/Sub channel for each recipient userId → chat server B, subscribed on behalf of its connected users, pushes newMessage down the recipient's socket.
  3. Ack: the recipient device acks → chat server B deletes that device's inbox row. No ack (dropped frame, crashed app) → the row stays, and the message is re-delivered on the next inbox sync.
  4. Offline: no subscriber on the channel, nothing happens — the inbox row waits. On reconnect the client calls GET /inbox?since={seq} and drains everything it missed.
  5. Media: client gets a presigned URL, uploads to S3 directly, and sends a message whose body is the S3 key — chat servers never carry bytes.
06

Deep dives — where the interview is won

Phase 01

Write the inbox first — the pub/sub hop is allowed to fail

Redis Pub/Sub is at-most-once: no subscriber at the instant of publish, message gone. That sounds disqualifying for guaranteed delivery, and it would be — if the bus were the durability mechanism. It isn't. The inbox row is written and acknowledged before the publish, so the invariant is: every undelivered message has a row, and rows are only deleted by client acks. The bus is purely a latency optimisation that turns 'wait for the next sync' into 'arrives in 100 ms'.

Gaps in the fast path still need detecting. Two mechanisms: the client polls its inbox every 30–60 s as a safety net, and every message carries a per-chat sequence number — a client seeing seq 7 after seq 5 knows to sync immediately rather than wait for the poll. Piggyback the latest sequence numbers on heartbeats and gap detection costs nothing extra.

send inbox push Send API Inbox store cassandra Redis pubsub 1. append 2. publish
write inbox first (durable). pubsub is a nice-to-have accelerator, allowed to drop.
Trade-offTwo writes (message + inbox) before the sender's ack costs send latency; the alternative — publish first, persist async — is faster and silently loses messages when a server dies between the two steps.
Phase 02

Routing: a Redis channel per user, never a Kafka topic per user

The recipient's socket lives on some other chat server, so I need a way to reach it. The clean answer is a Pub/Sub channel per userId: whichever server holds a user's socket subscribes to that user's channel, and senders publish blind. Nobody maintains a userId→server map that must be kept consistent through crashes and reconnects — subscription is the routing table, and it self-heals when a client reconnects elsewhere.

The trap answer is Kafka with a topic per user, and it's worth killing with arithmetic: a Kafka topic costs roughly 50 KB of cluster metadata, so a billion users is ~50 TB of pure topic bookkeeping before the first message flows. Kafka also persists everything — but I already have durability in the inbox, so I'd be paying for a guarantee twice. Per-user channels beat per-chat channels too: WhatsApp traffic skews heavily 1:1, and per-user means a device's server holds exactly one subscription per connected user regardless of how many chats they're in.

publisher bus gateway Sender Channel u:42 GW box holding sub GW box no sub PUB u:42 SUB match
one redis channel per user id. no kafka topic-per-user (metadata explosion).
Trade-offRedis Pub/Sub gives me a routing fabric with no cleanup and no consistency protocol, and in exchange delivers nothing to the disconnected — which is exactly the failure the inbox already absorbs.
Phase 03

200M connections: liveness is an in-memory problem

TCP won't tell you a phone died — a peer that vanishes behind a dropped cell connection looks identical to a silent one for minutes. So liveness is application-level: ping every 10–30 s, expect a pong within ~5 s, and a missed pong means the server closes the socket and unsubscribes that client's channels; the client reconnects (through the load balancer, to any server) and drains its inbox. That reconnect-and-sync loop is the same machinery as offline delivery, which is the sign of a sound design — failure handling reuses the normal path.

The arithmetic is the discipline: 20M heartbeats/s must terminate entirely in the chat server's memory — a socket map and a timer wheel. The one thing users see derived from liveness is last-seen, and the write pattern is: update on disconnect, plus a lazy conditional write at most once every few minutes while connected. Writing last-seen per heartbeat is a 20M writes/s self-inflicted outage, and interviewers listen specifically for whether you step around it.

client gateway state Phone Gateway 50k sockets in RAM Redis TTL presence keys WSS SET ex 30s
each gateway holds ~50k sockets in memory; heartbeat sets a redis presence key with TTL.
Trade-offA 10 s heartbeat means up to ~15 s of believing a dead client is alive — messages pushed into that window get no ack and simply wait in the inbox, so I tune the interval for battery and server load, not for detection speed.
Phase 04

Multi-device: the inbox is per client, not per user

One account, three devices, each with its own connection, its own delivery state, and its own gaps. If the inbox were keyed by userId, the first device to ack would delete the row and starve the others. So every message fans out to one inbox row per registered client, and acks are per-client too — the phone acking does nothing to the laptop's copy.

Routing barely changes: publish once to the userId channel, and every chat server holding one of that user's sockets picks it up and pushes to its device. The cost of the cap matters here — ~3 clients per user bounds the inbox write amplification at 3×, which is why the earlier estimate said ~100k row writes/s against 40k messages/s. An unbounded device list would make every send a fan-out of unknown size.

user svc cursors Phone Laptop Inbox svc per-device cursor cursor A cursor B
each device has its own inbox cursor; server tracks per-(user, device) unread.
Trade-offPer-client inboxes multiply writes and storage by the device cap, and the alternative — a shared inbox with per-device read cursors — halves the writes but turns deletion into a min-cursor computation across devices that may not connect for weeks.
07

Follow-ups you should expect

How would this change for Telegram-style groups with 100,000 members?
Push collapses — one message becoming 100k inbox rows and 100k pushes is fan-out amplification I capped groups at 100 to avoid. For huge groups I flip to pull: the message is written once to the group's timeline, members fetch on open, and only currently-active members get a lightweight 'new activity' ping. It's the same push/pull threshold logic as celebrities in a news feed.
Where does end-to-end encryption change your design?
Surprisingly little structurally — the server already treats message bodies as opaque payloads to store and route, and per-device inboxes map directly onto per-device encryption sessions. What it kills is every server-side feature that reads content: search, spam filtering, previews all move client-side. I'd flag key distribution and device verification as the real new subsystem.
What happens when a chat server crashes with 500k connections on it?
Every message in flight is already in the inbox, so nothing is lost — this is the design's whole point. Clients detect the dead socket via missed heartbeats within seconds, reconnect through the load balancer to surviving servers, resubscribe, and drain their inboxes. The real risk is the reconnect stampede: 500k clients arriving at once, so clients back off with jitter and servers shed load by deferring inbox syncs.
A Redis Pub/Sub node dies — what do users experience?
Messages routed through that node stop arriving in real time, but every one of them sits in an inbox row, so the 30–60 s polling fallback delivers them — users see a delay, not a loss. Redis cluster failover restores the fast path in seconds, and chat servers re-establish subscriptions on reconnect. I'd say explicitly: the bus is designed to be losable.
How do you order messages within a chat?
I don't attempt strict global ordering — with concurrent senders on different servers it needs coordination that would cost more latency than it's worth in a chat. Server-receive time on NTP-synced clocks plus a per-chat sequence number gives an order that's consistent for all readers, and clients render by it. Rare cross-sender reorderings within a second or two are invisible in practice.

Where candidates lose the room

  • No client ack protocol — delivery 'guaranteed' by the act of sending a WebSocket frame, which is precisely the guarantee TCP doesn't give you across a dying mobile connection.
  • Designing only the online path and bolting on offline storage when prompted — the inbox is the core of this question, not an edge case.
  • Kafka with a topic per user for routing — ~50 KB of metadata per topic × 1B users is 50 TB of bookkeeping, and it duplicates a durability guarantee the inbox already provides.
  • Writing last-seen (or anything) to a database per heartbeat — 20M writes/s that a socket map in memory handles for free.
  • Pushing media bytes through chat servers instead of presigned S3 URLs, turning a message fabric into a file-transfer bottleneck.
  • No failure walk-through — what happens on chat-server crash, Redis loss and network partition is where this question's marks actually live.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.