Handbooks / System Design Case Studies / Chapter 6
Design a Chat App like WhatsApp
13 pages · ~18 min✓ Reviewed
Builds on Design a Notification System. Next up: Design a News Feed like Instagram.
Problem, Scope and Requirements
1. Scope (WhatsApp Chat App)
| Features | |
|---|---|
| In | 1:1 chat, groups up to 1,024 members, media, receipts, presence, push, E2EE |
| Out | Calls, status, payments |
2. Scale and Storage
| Metric | Estimate | Math |
|---|---|---|
| Users | 2B total, 500M DAU | given |
| Messages | 100B/day | given |
| Text storage | 10 TB/day | 100B × ~100 B |
| Media storage | ~1 PB/day | 5% of msgs × ~200 KB |
| Sockets | 500M concurrent | one per online user |
| Gateways | 2,500+ servers | 500M ÷ ~200K each |
- 100B msgs/daydaily volume
- ÷ 86,400 sseconds per day
- ≈ 1.2M msg/saverage
- ~3M msg/speak
High-Level Architecture
1. Sockets and the Gateway
WhatsApp-style Chat App: stateful sockets at the edge, stateless services in the middle, Kafka behind them.
- Clientapp, 1 WebSocket
- L4 LBTCP, no parsing
- Gateway~200K sockets each
- Chat servicestateless logic
- Kafkaby conversation_id
- Each client holds one persistent WebSocket for all chats
- L4 load balancer spreads TCP connections; sockets then stay pinned to a gateway
WebSocket Connection Gateway
Gateway tier (WhatsApp Chat App): 500M concurrent sockets ÷ ~200K per server ≈ 2,500+ gateways.
1. Responsibilities and Capacity
- Hold long-lived WebSockets, one per online device
- Authenticate the socket once, at connect
- Heartbeat every ~30 s to detect dead peers
- Forward frames to the chat service; gateway logic stays stateless
One-to-One Message Flow and Ordering
1. Send Flow (WhatsApp)
- Senderclient_msg_id
- Gateway AWebSocket frame
- Chat servicevalidate, persist
- Session lookupuser_id → gateway
- Gateway Bpush frame
- Receiverdevice
- Server ACK goes back to the sender only after persist
- ACK means stored (one tick), not delivered
- Session miss in Redis → inbox queue + push job
- Target: online delivery p99 < 200 ms
2. Idempotency and IDs
Message Storage
1. Access Pattern and Store
Chat App (WhatsApp): 100B msgs/day × ~100 B ≈ 10 TB/day of text, ~1.2M writes/s average.
- Write-heavy: every message is one insert
- Append-only: messages are never updated in place
- Read recent: newest messages of one conversation
- So use a wide-column store (Cassandra / HBase), not SQL
Delivery and Read Receipts
1. Three Receipt States
| State | Trigger | Ticks |
|---|---|---|
| sent | Server persisted the message | one grey tick |
| delivered | Recipient device received it | two grey ticks |
| read | Recipient opened the chat | two blue ticks |
Group Messaging and Fan-out
Running example: the WhatsApp-style chat app. One group message becomes up to 1,024 deliveries at ~3M msg/s peak.
1. Why Fan Out on the Server
| Client sends N copies | Server-side fan-out | |
|---|---|---|
| Uplink per message | up to 1,024 uploads | 1 upload |
| Mobile bandwidth | drained on cellular | saved |
Offline Delivery and Push Notifications
1. Offline Path and Push
- Session lookup in Redis misses → recipient is offline
- Message stays in the recipient's inbox queue
- A push job is created for the recipient's devices
- Message is already persisted, so push is only a wake-up
- Session lookupmiss
- Inbox queuekeep message
- Push jobKafka
- APNs / FCMwake device
- App fetchesdrain inbox
Presence and Typing Indicators
1. Presence = Heartbeat + TTL
- Online = heartbeat seen within ~60 s
- Gateway heartbeat every ~30 s refreshes a Redis key with TTL 60 s
- Key
presence:{user_id}expires on its own: no cleanup job last_seenis written to the store only on disconnect- Heartbeats are ~500M sockets ÷ 30 s ≈ 17M refreshes/s at peak
r.set(f"presence:{uid}", gateway_id, ex=60) # heartbeat r.exists(f"presence:{uid}") # online? # on disconnect db.set_last_seen(uid, now())
TTL key for online, durable write for last_seen
2. Who Gets Presence and Typing
Media Sharing and End-to-End Encryption
1. Media Flow (WhatsApp Chat App)
- 1Encryptrandom key on the client
- 2Uploadciphertext via pre-signed URL
- 3Object storeblob lands in S3
- 4Send messageURL + key + hash, E2EE
{ "type": "image",
"url": "https://cdn.../blob/9f3a",
"key": "<random 256-bit key>",
"sha256": "<hash of ciphertext>",
"thumb": "<tiny blurred preview>" }travels inside the E2EE message, never in the clear
- Server stores ciphertext only; it never holds the key
- Thumbnails are made on the client and sent inline
- CDN caches ciphertext; the key stays in the chat message
Scaling, Failure Handling, Trade-offs and Interview Questions
1. Scale Out and Multi-Region
Running example: WhatsApp-style chat app, 500M concurrent sockets, ~3M msg/s peak, 99.99% availability.
- Shard chat service and storage by conversation_id: a chat lives on one partition, so order stays local
- Shard sessions (Redis) by user_id with consistent hashing: adding a node moves only ~1/N of keys
- Add gateways horizontally: ~200K sockets each, 2,500+ nodes for 500M connections
- Home each user to the nearest region; Kafka replicates messages across regions
- Region failover: DNS health checks repoint users; clients reconnect with backoff + jitter
WhatsApp Design Cheatsheet
1. Numbers (WhatsApp Chat App)
| Metric | Estimate | Derived from |
|---|---|---|
| Concurrent sockets | 500M | 500M DAU online |
| Sockets per gateway | 200K | 500M / 200K = 2,500+ servers |
| Message rate | 1.2M msg/s avg, ~3M peak | 100B msgs/day |
| Text storage |
Check yourself
The ACK for a sent message is lost after the server persisted it. The client retries, but its code builds a fresh UUID for every attempt. What does the receiver see, and what is the fix?
- The server cannot tell the retry from a new message, so it stores a second copy
- The receiver sees the same text twice
- Fix: create
client_msg_idonce per message and reuse it on every retry - The server then finds the ID, skips the write and returns the stored result
# bug: new id on every attempt for attempt in range(3): send(body, client_msg_id=uuid4())