Design WhatsApp / a messaging service
The setup
"Design the messaging backbone of WhatsApp. Focus on 1-on-1 chats. Include sending and delivering messages, sent/delivered/read receipts, and online presence. Assume 2 billion users globally; peak ~100M concurrent connections."
The interviewer is probing for: connection management at scale, message delivery semantics, ordering guarantees, and the operational realities of mobile networks. They're not interested in encryption details ("assume it's a black box that turns plaintext into bytes"). They want to know if you understand the distributed-systems realities of "deliver a message reliably, eventually, in order."
Clarifying questions
| Question | Why it matters | Story answer |
|---|---|---|
| Just 1:1, or also group chats? | Groups change the architecture significantly (fan-out problem) | "1:1 only this session. Mention groups at the end." |
| End-to-end encryption? | Server cannot read payload — but still routes by metadata | "Yes, but model only the metadata path. Encrypted payload is opaque to us." |
| Max message size? Media? | Text vs media has very different storage and delivery patterns | "Up to 1MB text. Media uploaded to object store separately; message contains a URL." |
| Offline delivery — how long do we hold undelivered messages? | Determines storage retention and recipient-side state | "30 days. Beyond that, drop with a soft notification." |
| Latency target when both online? | Anchors the architecture | "p99 under 500ms end-to-end. Sub-second feels instant; multi-second feels broken." |
| Ordering guarantee? | Within-conversation ordering is essential; cross-conversation often not. | "Strict per-conversation order. Across conversations: no guarantee." |
| What happens on bad network? | Mobile networks are hostile; the design must be reconnect-tolerant. | "Frequent disconnects, packet loss, varying latency. The design must absorb all of this." |
Capacity estimation
Users: 2B total, ~500M active in a day, ~100M concurrent peak Messages: ~100B / day → ~1.2M msg/sec average, 5M/sec peak Avg message size: ~100 bytes text + ~200 bytes metadata = 300 bytes Storage: 100B × 300 bytes = 30 TB / day raw 30-day retention before drop: ~900 TB working storage Connections: 100M concurrent WebSockets Connection bandwidth: ~10 KB / sec sustained per connection (heartbeats, presence) = 1 TB/sec total (handled by edge tier)
The three fundamental challenges
WhatsApp-style messaging breaks down into three subproblems, each its own engineering effort:
- Connection management: holding 100M concurrent WebSocket connections, knowing where each user is currently connected, and routing messages to the right server.
- Message delivery: reliably moving a message from sender to recipient with ordering and at-least-once guarantees, including offline buffering.
- State synchronization: delivery receipts, read receipts, presence, typing indicators. All of these are real-time state pushed back to interested parties.
The architecture has separate concerns for each, glued together by a central message service.
Design Decision 1: Connection architecture
100M concurrent WebSockets is a hard problem. Per-server connection limits:
| Server type | Stable concurrent WSS per node | Notes |
|---|---|---|
| Standard Linux server | ~50-100K | Limited by file descriptors, ephemeral ports, memory per connection |
| Specialized (e.g., Linkerd2-proxy, custom epoll) | 200-500K | Engineered for connection density |
| Indian-quality mobile networks (frequent reconnects) | Half the above | Reconnect storms; idle but consuming resources |
For 100M concurrent at ~30K stable per node: ~3,300 connection-handling nodes. That's a "WebSocket gateway tier" — thin, stateful (holds the connection), specialized for connection management. Other services don't know about connections directly.
Connection registry: a key-value store mapping user_id → gateway_node_id. Updated when user connects/disconnects. Redis is fast enough; sharded by user_id. Lookup latency <1ms.
Design Decision 2: Message delivery path
sequenceDiagram
participant S as Sender
participant SG as Sender Gateway
(WebSocket)
participant Q as Recipient queue
participant RG as Recipient Gateway
participant R as Recipient (online)
S->>SG: send "hello", msg_id=42
SG->>Q: enqueue for recipient
SG-->>S: server-ack (✓)
alt recipient online
Q->>RG: deliver
RG->>R: push msg_id=42
R-->>RG: client-ack
RG-->>S: delivered-ack (✓✓)
else recipient offline
Q->>Q: hold until reconnect
Note over R: reconnects later
R->>RG: HELLO last_seen=41
Q->>RG: deliver msg_id=42
end
When Alice sends "hi" to Bob, what's the flow?
1. Alice's WSS gateway receives the encrypted payload (Alice's WSS connection)
2. Gateway forwards to Message Service with (from=Alice, to=Bob, msg_id, payload, timestamp)
3. Message Service:
a. Persists to hot store (Redis): bob's_undelivered_queue.push(msg_id)
b. Persists to cold store (Cassandra) asynchronously for history
c. Looks up Bob's gateway in connection registry
d. If Bob is online:
→ Send via internal pub/sub to Bob's gateway
→ Gateway forwards over WSS to Bob's device
→ Bob's device acks delivery
→ Ack flows back: Bob's gateway → Message Service → mark delivered → notify Alice's gateway → Alice sees "delivered"
e. If Bob is offline:
→ Leave in undelivered queue
→ Will flush on Bob's next connect
Two storage tiers: hot store (Redis) for undelivered queue and "recent" messages; cold store (Cassandra) for historical messages users can scroll back through.
| Tier | Purpose | Why this choice |
|---|---|---|
| Redis (hot) | Undelivered queues; very recent messages | Sub-ms operations; sorted sets for ordering; TTL for auto-cleanup |
| Cassandra (cold) | Full message history | Write-heavy, time-ordered access pattern, scales horizontally; partition by (conversation_id, time_bucket) |
Conversation_id is the canonical pair: min(user_a, user_b) || "_" || max(user_a, user_b). Within a partition (one conversation, one time bucket), messages clustered by timestamp — efficient "scroll back" reads.
Design Decision 3: Ordering
Per-conversation strict ordering is required. Two challenges:
- Alice sends 3 rapid messages. Bob must see them in order.
- Alice on phone, Alice on laptop both send messages. Bob must see a consistent order.
Approach: every message gets a monotonically increasing message_id per conversation, assigned server-side at the Message Service. The client provides a clock hint (its local timestamp) but the server is authoritative. This avoids client clock skew issues.
Within a conversation, messages are dispatched in message_id order. The Message Service writes to Kafka with partition_key = conversation_id, so all messages for a conversation go to the same Kafka partition — consumed in order by Bob's gateway.
Cross-examination round 1: bad network
A: Connection registry has Bob's new gateway. Old gateway's lingering messages drain via the message service which always routes through the current registry lookup. There's no per-gateway state assumption — every message routes through the registry. Sticky-by-user routing helps for predictability (same user → same gateway when possible) but it's not required for correctness.
A: Thundering herd. Mitigations: client-side exponential backoff with jitter (already standard in mobile clients), so reconnects spread over minutes. Gateway tier auto-scales on connection count. The undelivered queue absorbs the message backlog. Eventually all reconnects complete and the system catches up. Critical: the message service does not block reconnects waiting for undelivered messages to be drained — it accepts the connection immediately, and message replay happens asynchronously.
Cross-examination round 2: presence
presence:alice = (last_heartbeat_timestamp, gateway_node_id) with TTL 60 seconds. To check Alice's presence: read the Redis key. If TTL valid: online. Otherwise: "last seen at $timestamp". This is pull-on-view — Bob's app fetches Alice's presence when viewing her chat, not via continuous push.A: Two strategies. (1) Pull-on-view: only fetch presence for visible contacts; lazy-load on scroll. Avoid prefetching all 200. (2) Subscription pattern: Bob's app subscribes to presence-change events for his contacts via pub/sub. Server pushes changes to him when contacts come online/offline. This is more like "interesting set" management — only push deltas for what Bob is currently viewing or has expressed interest in. WhatsApp uses some hybrid.
A: Ephemeral, low-stakes. Client sends "typing started" over WSS; server forwards to recipient if they're online. Times out after 5 sec of no further typing events. Doesn't persist; no buffering for offline recipient. Simple pub/sub through the message service path.
Cross-examination round 3: storage and retention
messages table: PARTITION KEY: (conversation_id, year_month) CLUSTERING KEY: message_timestamp DESC, message_id For "show me the last 50 messages in conversation X": SELECT * FROM messages WHERE conversation_id = $1 AND year_month = current_month ORDER BY message_timestamp DESC LIMIT 50 Why year_month bucketing: prevents unbounded partition growth. A single conversation could accumulate millions of messages over years. Without bucketing, the partition becomes a hot, oversized partition.
A: Yes — Cassandra can read across partitions. The "scroll back" is bounded; usually you fetch 50 messages at a time. Even with year-month buckets, fetching back is a sequential cursor across partitions. Pagination handles it: client passes "before timestamp X" cursor; server reads bucket containing X, returns messages older than X up to 50 results.
A: ~100B messages/day × 300 bytes × 365 days = ~11 PB/year. With replication factor 3: ~33 PB. Cassandra at this scale is operationally heavy (10K+ nodes); WhatsApp likely uses tiered storage — hot data (last 30 days) in Cassandra, older data archived to object storage (cheaper $/GB, slower access for rare deep-scroll requests).
Failure mode analysis
| Failure | Impact | Mitigation |
|---|---|---|
| WSS gateway node crashes | ~30K users' connections drop | Auto-reconnect on client; new connection lands on healthy node; connection registry updated. ~5-30 sec disruption. |
| Message service unavailable | Messages can't be sent | WSS gateway queues outbound messages briefly; if MS unavailable >30s, client sees "message failed to send"; retry on next attempt. |
| Redis (undelivered queue) loses data | Some recent undelivered messages lost | Cassandra is durable source. On recovery, undelivered queue rebuilt from "messages with no delivered receipt" query. Brief delay; no data loss long-term. |
| Cassandra cluster degraded | Write latency up; history fetches slow | Hot path (recent messages via Redis) unaffected. Older messages slow but still served. Compaction may stall; eventually catches up. |
Scaling phases
| Scale | Architecture |
|---|---|
| 1K concurrent | Single Node.js server holding WebSockets. Postgres for messages. |
| 100K concurrent | Multiple gateway nodes behind load balancer. Connection registry in Redis. Postgres still works for messages. |
| 10M concurrent | Cassandra for messages. Sharded Redis for registry. Hundreds of gateway nodes. |
| 100M concurrent | Multiple datacentres; messages routed by recipient's region. Cassandra clusters per region with cross-region replication. |
| 2B users | Global infrastructure with regional message stores. Compliance and data residency considerations (some regions must keep their data in-country). |
Takeaway
Messaging looks simple ("just send a message from A to B") but at scale every primitive becomes a distributed-systems problem. Connection management, delivery ordering, presence fanout, offline buffering, multi-region routing — each is its own subsystem. The architectural discipline is to keep each subsystem's responsibility tight (WSS gateway only handles connections; message service handles persistence and routing; presence is its own thing) and to design every flow with reconnect/retry/duplication in mind because mobile networks are hostile by default.
The deeper lesson: state on the edge (mobile clients) is fundamentally messy and must be treated as such. Server-side correctness is necessary but not sufficient — the protocols between server and client must be reconnect-safe, message-id-based, idempotent. Every protocol decision must answer: what happens if this fails partway through?