Hirestack
Hirestack
Worked Example 15.2 · 2 of 15

Design WhatsApp / a messaging service

📖 9 min read · 13 sections · May 2026
Asked at: Meta Slack Discord Microsoft

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

QuestionWhy it mattersStory 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:

  1. Connection management: holding 100M concurrent WebSocket connections, knowing where each user is currently connected, and routing messages to the right server.
  2. Message delivery: reliably moving a message from sender to recipient with ordering and at-least-once guarantees, including offline buffering.
  3. 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 typeStable concurrent WSS per nodeNotes
Standard Linux server~50-100KLimited by file descriptors, ephemeral ports, memory per connection
Specialized (e.g., Linkerd2-proxy, custom epoll)200-500KEngineered for connection density
Indian-quality mobile networks (frequent reconnects)Half the aboveReconnect 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.

TierPurposeWhy this choice
Redis (hot)Undelivered queues; very recent messagesSub-ms operations; sorted sets for ordering; TTL for auto-cleanup
Cassandra (cold)Full message historyWrite-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:

  1. Alice sends 3 rapid messages. Bob must see them in order.
  2. 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

Interviewer: Bob is on a flaky train wifi. He receives message 1, then his connection drops. Message 2 arrives at his gateway while he's offline. Walk through.
When Bob disconnects, the WSS gateway notices (TCP RST, ping timeout, or graceful close). It removes Bob from the connection registry. New messages for Bob route to "Bob is offline" path — accumulate in his undelivered queue. The message service had been about to send message 2 — it now sees Bob offline; persists message 2 in the undelivered queue instead. When Bob reconnects (maybe 30 seconds later), his client requests "messages newer than message_id X" (the last he received). The gateway fetches from the undelivered queue + recent history; sends in order; client acks each.
Follow-up: What if Bob's reconnect happens to a different gateway node (different IP, load balancer hashes differently)?
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.
Follow-up: What if 5M users simultaneously reconnect after a network event?
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

Interviewer: Show me presence — "Alice is online", "Alice was last seen 5 min ago", "Alice is typing".
Heartbeat-based. Each active WSS sends a heartbeat every ~30 seconds. Gateway updates a Redis key: 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.
Follow-up: Bob has 200 contacts. Showing them all as "online/offline" requires checking all 200. Latency?
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.
Follow-up: Typing indicators?
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

Interviewer: Walk me through how messages are persisted in Cassandra.
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.
Follow-up: User scrolls back to messages from 2 years ago. Multi-bucket query — efficient?
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.
Follow-up: How much storage for 2B users?
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

FailureImpactMitigation
WSS gateway node crashes~30K users' connections dropAuto-reconnect on client; new connection lands on healthy node; connection registry updated. ~5-30 sec disruption.
Message service unavailableMessages can't be sentWSS gateway queues outbound messages briefly; if MS unavailable >30s, client sees "message failed to send"; retry on next attempt.
Redis (undelivered queue) loses dataSome recent undelivered messages lostCassandra is durable source. On recovery, undelivered queue rebuilt from "messages with no delivered receipt" query. Brief delay; no data loss long-term.
Cassandra cluster degradedWrite latency up; history fetches slowHot path (recent messages via Redis) unaffected. Older messages slow but still served. Compaction may stall; eventually catches up.

Scaling phases

ScaleArchitecture
1K concurrentSingle Node.js server holding WebSockets. Postgres for messages.
100K concurrentMultiple gateway nodes behind load balancer. Connection registry in Redis. Postgres still works for messages.
10M concurrentCassandra for messages. Sharded Redis for registry. Hundreds of gateway nodes.
100M concurrentMultiple datacentres; messages routed by recipient's region. Cassandra clusters per region with cross-region replication.
2B usersGlobal 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?