Design a Messaging App (WhatsApp / Telegram)
An end-to-end interview-ready walkthrough — from back-of-envelope math through deep dives on WebSocket management, message ordering, group fan-out, E2E encryption, and multi-device sync. Structured to mirror the arc of a 45-minute system design interview.
Requirements
A messaging app is deceptively complex. On the surface it's "send text from A to B" — but at WhatsApp/Telegram scale, you're solving real-time delivery across 2 billion devices, message ordering without a global clock, end-to-end encryption that even your own servers can't break, and group fan-out to millions of members. The requirements you anchor here determine whether you build a weekend project or a planetary-scale communication system.
Functional Requirements
Core business logic & features
- 01.1:1 MessagingUsers can send text messages to any other user. Messages are persisted and available on reconnect.
- 02.Group MessagingUsers can create groups (up to 100K members for Telegram-scale). Messages fan out to all participants.
- 03.Delivery StatusThree-state delivery tracking: sent (server received), delivered (recipient device received), read (recipient opened).
- 04.Media SharingSupport images, videos, audio messages, and documents up to 2GB. Thumbnails generated server-side.
- 05.Online PresenceShow online/offline status and 'last seen' timestamp. Typing indicators for active conversations.
- 06.Multi-Device SyncUsers can be logged in on phone + desktop + tablet simultaneously. All devices stay in sync.
Non-Functional
System constraints
Latency
Message delivery in <500ms end-to-end for online recipients. Typing indicators in <200ms.
Scale
2B registered users, 500M DAU, 100B messages/day. Peak: 50M concurrent WebSocket connections.
Availability
99.99% uptime. Messaging is critical infrastructure — downtime means people can't communicate.
Security
End-to-end encryption for all 1:1 messages. Even server operators cannot read message content.
🎯 Clarifying questions that change the design
Each of these steers you toward a fundamentally different architecture:
- What's the max group size? 256 members (WhatsApp) vs 200K (Telegram) changes fan-out strategy entirely. Small groups can fan-out on write; large groups must fan-out on read.
- Is message history stored server-side or client-side? WhatsApp stores minimally on server (E2E encrypted, client is source of truth). Telegram stores everything server-side (cloud-first). This changes your storage model.
- Multi-device or single-device? Single-device (original WhatsApp) is simpler — one inbox queue. Multi-device requires per-device delivery tracking and sync protocols.
- Do we need message search? Searching E2E encrypted messages requires client-side indexing. Server-side search only works for unencrypted messages.
- Voice/video calls? Real-time media is a separate system (WebRTC, TURN/STUN servers). Scope it out unless asked.
- Message retention policy? Keep forever vs auto-delete after N days changes storage sizing dramatically.
In scope vs out of scope
| In Scope | Out of Scope | Why |
|---|---|---|
| 1:1 and group text messaging | Voice/video calls (WebRTC) | Real-time media is a separate system with different latency models |
| Media sharing (images, video, docs) | Stories / status updates | Ephemeral content is a feed problem, not a messaging one |
| Delivery receipts (sent/delivered/read) | Payment integration (WhatsApp Pay) | Fintech is its own 45-minute interview |
| End-to-end encryption (1:1) | Full E2E for large groups (>256) | Group E2E at scale requires complex key rotation — mention but don't deep-dive |
| Online presence + typing indicators | AI chatbots / message translation | ML features, not distributed systems |
| Multi-device sync (phone + desktop) | Cross-platform message backup/restore | Backup is a storage/export concern, not real-time delivery |
| Push notifications for offline users | SMS fallback delivery | Carrier integration is a vendor concern, not architecture |
💡 Interviewer signal
The strongest opening: "I'll focus on the real-time message delivery pipeline — that's where the distributed systems complexity lives. The core challenge is maintaining message ordering, exactly-once delivery semantics, and sub-500ms latency across 2 billion devices with persistent connections. Media upload is an async pipeline I'll cover separately." This shows you know where the hard problems are.
Back-of-Envelope Estimation
Messaging systems have a unique traffic shape: the write path and read path are nearly symmetric (every message sent is a message received), but the connection count is the real bottleneck — not throughput. A single WebSocket connection consumes memory and file descriptors whether or not messages are flowing. Derive every number out loud — interviewers reward the reasoning process.
Traffic: Messages per second
Given:
100B messages/day
500M DAU (daily active users)
Average user sends ~40 messages/day and receives ~160 (groups amplify)
Message throughput:
100B / 86,400s ≈ 1.16M messages/sec (average)
Peak ≈ 3× average ≈ 3.5M messages/sec (evening hours, global)
Per-user perspective:
Average: 200 messages/day (sent + received) = 1 message every 7 minutes
Power user: 2000 messages/day = 1 message every 43 seconds
Implication:
→ This is NOT a high-QPS-per-server problem like URL shortener.
→ The challenge is CONCURRENT CONNECTIONS, not request throughput.
→ Each WebSocket server handles ~500K connections but only ~50K msg/sec.
Connection count
The defining constraint of a messaging system is . Every active user holds one open connection. This is fundamentally different from stateless HTTP services.
Concurrent connections (peak):
500M DAU × ~10% online at any moment ≈ 50M concurrent connections
(WhatsApp reported ~50M concurrent in 2020 with 2B users)
Per-server capacity:
Optimized WebSocket server (Go/Rust/C++): ~500K connections per box
With 64GB RAM: ~20KB per connection × 500K = 10GB for connections
Remaining RAM for message routing, buffers, TLS state
Server fleet:
50M connections ÷ 500K per server = 100 WebSocket servers (minimum)
With 2× headroom for failover: ~200 WebSocket servers globally
Implication:
→ Connection state is the bottleneck, not CPU.
→ Server failure means 500K users instantly disconnect and must reconnect.
→ Need connection draining and graceful handoff during deploys.
Storage
Per-message estimate:
message_id : 16 bytes (UUID or Snowflake)
conversation_id : 16 bytes
sender_id : 16 bytes
content : 200 bytes (average text message)
timestamp : 8 bytes
status : 1 byte
metadata : 50 bytes (reply_to, forwarded, etc.)
─────────────────────────────
Raw message ≈ 300 bytes
With Cassandra overhead (tombstones, bloom filter, index) ≈ 500 bytes
Daily storage growth (text only):
100B messages/day × 500 bytes ≈ 50 TB/day
→ ~18 PB/year (text messages alone)
Media storage:
Assume 5% of messages have media, average 2MB per media file
100B × 5% × 2MB = 10 PB/day (!)
→ Media dominates storage by 200×. Object storage (S3) is mandatory.
Implication:
→ Text messages: Cassandra/ScyllaDB with TTL-based compaction
→ Media: S3 with lifecycle policies (hot → warm → cold)
→ Message retention: 30 days on server (WhatsApp model) vs forever (Telegram)
→ With 30-day retention: ~1.5 PB hot text storage at any time
Bandwidth
Text messages:
Inbound: 1.16M msg/sec × 300 bytes ≈ 350 MB/s ≈ 2.8 Gbps
Outbound: Same (every message sent is delivered to 1+ recipients)
With group amplification (avg 3 recipients): ~8.4 Gbps outbound
Media:
5M media messages/sec × 2MB average = 10 TB/sec (!)
→ This is why media goes through CDN, not through chat servers.
→ Chat servers only relay a URL/thumbnail, not the actual file.
WebSocket overhead:
50M connections × 64-byte heartbeat every 30s = 100 MB/s just for keepalives
Implication:
→ Text bandwidth is manageable on modern NICs (25-100 Gbps per server)
→ Media MUST be decoupled — upload to S3, share URL via message
→ Heartbeat traffic is non-trivial at scale — batch or reduce frequency
Metadata and presence
Online/offline status updates:
500M DAU × 2 transitions/day (online + offline) = 1B events/day
Peak: ~30K status changes/sec
Typing indicators:
Assume 10% of DAU typing at any moment, 1 event/sec while typing
50M × 1/sec = 50M typing events/sec (!)
→ Typing indicators are EPHEMERAL — never persisted, never queued.
→ Delivered best-effort via the existing WebSocket connection.
→ If recipient is offline, typing indicator is simply dropped.
Delivery receipts:
100B messages × 2 receipts each (delivered + read) = 200B receipts/day
≈ 2.3M receipts/sec
→ Receipts are batched and sent periodically, not per-message.
Implication:
→ Presence is a hot-path problem but NOT a storage problem.
→ Typing indicators must NEVER hit a database or queue.
→ Delivery receipts can be batched (every 5s) to reduce write amplification.
🧮 The numbers that drive the design
Quick Revision Cheat Sheet
Message throughput (peak): ~3.5M messages/sec
Concurrent connections: ~50M WebSocket connections
WebSocket servers needed: ~200 (500K conn each)
Text storage (daily): ~50 TB/day
Media storage (daily): ~10 PB/day (S3)
Hot text storage (30-day): ~1.5 PB
Delivery receipts: ~2.3M/sec (batchable)
Typing indicators: ~50M/sec (ephemeral, best-effort)
Presence updates: ~30K/sec (status changes)
Text bandwidth: ~8.4 Gbps outbound (with groups)
💡 The insight that separates senior answers
Most candidates focus on message throughput (1M msg/sec — manageable). The real constraint is 50M persistent connections. Each connection is stateful, consumes memory, and creates a routing problem: "which of my 200 servers is User X connected to right now?" This connection-routing problem is what makes messaging architecturally unique compared to stateless HTTP services.
API & Protocol Design
A messaging app uses two communication channels: a persistent connection for real-time message delivery, and a standard REST API for non-real-time operations (user management, media upload, conversation history). The WebSocket carries the hot path — every message, receipt, and typing indicator flows through it.
WebSocket frame protocol
The WebSocket connection carries structured frames — not raw text. Each frame has a type, a unique ID for acknowledgment, and a payload. This is the protocol that both client and server speak over the persistent connection.
{
"type": "message | ack | typing | presence | receipt | sync",
"id": "msg_7f3a9b2c",
"timestamp": 1716300000000,
"payload": { ... }
}
Message frame (client → server)
When a user sends a message, the client constructs a frame with a locally-generated message ID (for idempotency) and sends it over the WebSocket. The server acknowledges receipt with an ack frame containing the same ID plus the server-assigned sequence number.
// Client → Server: Send a message
{
"type": "message",
"id": "msg_7f3a9b2c",
"timestamp": 1716300000000,
"payload": {
"conversation_id": "conv_abc123",
"content": {
"type": "text",
"body": "Hey, are you free for lunch?"
},
"reply_to": null,
"mentions": []
}
}
// Server → Client: Acknowledgment (message accepted)
{
"type": "ack",
"id": "msg_7f3a9b2c",
"timestamp": 1716300000050,
"payload": {
"status": "accepted",
"sequence": 48291,
"server_timestamp": 1716300000045
}
}
// Server → Client: Rejection (validation failed)
{
"type": "ack",
"id": "msg_7f3a9b2c",
"timestamp": 1716300000050,
"payload": {
"status": "rejected",
"reason": "CONTENT_TOO_LONG",
"max_length": 65536
}
}
Delivery receipt frame (server → client)
When a message reaches the recipient's device, the recipient sends a delivery receipt back. The server then forwards this receipt to the sender. Read receipts work identically but are triggered when the user opens the conversation.
// Recipient → Server: Message delivered to device
{
"type": "receipt",
"id": "rcpt_x9k2m",
"payload": {
"message_ids": ["msg_7f3a9b2c", "msg_8a4b1d3e"],
"conversation_id": "conv_abc123",
"status": "delivered",
"device_id": "dev_iphone_14"
}
}
// Recipient → Server: Messages read by user
{
"type": "receipt",
"id": "rcpt_p3n7q",
"payload": {
"conversation_id": "conv_abc123",
"status": "read",
"read_up_to_sequence": 48291
}
}
// Server → Sender: Forward receipt
{
"type": "receipt",
"id": "rcpt_fwd_1",
"payload": {
"conversation_id": "conv_abc123",
"recipient_id": "user_bob",
"status": "delivered",
"message_ids": ["msg_7f3a9b2c"]
}
}
Typing indicator (ephemeral)
Typing indicators are fire-and-forget signals. They are never persisted, never queued, and never retried. If the recipient is offline or the WebSocket drops the frame, the indicator is simply lost — and that's fine. This is a deliberate design choice to avoid overwhelming the system with 50M events/sec.
// Client → Server: User is typing
{
"type": "typing",
"id": "typ_1a2b3c",
"payload": {
"conversation_id": "conv_abc123",
"action": "started"
}
}
// Automatically expires after 5 seconds if no "started" renewal.
// No "stopped" event needed — absence of "started" = stopped.
REST API (non-real-time operations)
Operations that don't need real-time delivery use standard REST. These include fetching conversation history (pagination), uploading media, managing contacts, and creating groups. The REST API is stateless and sits behind a standard load balancer.
| Method | Path | Purpose | Latency target |
|---|---|---|---|
GET | /api/v1/conversations/:id/messages | Fetch message history (cursor-paginated) | < 100ms p99 |
POST | /api/v1/media/upload | Upload media file (returns media_url) | < 2s (chunked) |
POST | /api/v1/conversations | Create a new group conversation | < 200ms p99 |
GET | /api/v1/conversations | List user's conversations (inbox) | < 100ms p99 |
PUT | /api/v1/conversations/:id/members | Add/remove group members | < 200ms p99 |
DELETE | /api/v1/messages/:id | Delete message (for everyone / for me) | < 100ms p99 |
Message history (cursor pagination)
Message history uses cursor-based pagination anchored on the sequence number. This avoids the offset-skip problem and gives consistent results even when new messages arrive during pagination.
GET /api/v1/conversations/conv_abc123/messages?before_sequence=48291&limit=50 HTTP/1.1
Authorization: Bearer <jwt>
--- 200 OK ---
{
"messages": [
{
"id": "msg_6e2a8b1d",
"sequence": 48241,
"sender_id": "user_alice",
"content": { "type": "text", "body": "See you at noon!" },
"timestamp": 1716299500000,
"status": "read"
},
...
],
"cursor": {
"before_sequence": 48241,
"has_more": true
}
}
Connection lifecycle
The WebSocket connection has a well-defined lifecycle: connect → authenticate → sync → active → disconnect. Understanding this lifecycle is critical because connection state drives the entire routing and delivery system.
1. CONNECT
Client opens WebSocket: wss://chat.app/ws
Server assigns connection to a WebSocket server via load balancer
(sticky by user_id hash for connection affinity)
2. AUTHENTICATE
First frame must be auth: { "type": "auth", "token": "<jwt>" }
Server validates JWT, extracts user_id
Registers (user_id, device_id) → (server_id, connection_id) in Redis
3. SYNC
Client sends last-known sequence per conversation
Server pushes any missed messages since that sequence
This handles the "offline gap" — messages received while disconnected
4. ACTIVE
Bidirectional message flow
Server sends heartbeat every 30s; client responds with pong
If 3 heartbeats missed → server closes connection, marks user offline
5. DISCONNECT
Client sends close frame (graceful) or TCP drops (ungraceful)
Server removes routing entry from Redis
Queues any in-flight messages to offline inbox for push notification
🔑 Why client-generated message IDs
The client generates the message ID (UUID) before sending. This enables idempotent retries — if the WebSocket drops mid-send, the client resends the same frame with the same ID. The server deduplicates by ID, preventing double-delivery. Without this, network retries would create duplicate messages — the most common bug in naive chat implementations.
💡 Protocol choice: why not gRPC or MQTT?
gRPC bidirectional streaming works but adds protobuf compilation complexity on mobile clients and doesn't work through all corporate proxies. MQTT (used by Facebook Messenger) is lighter-weight but less flexible for complex frame types. WebSocket + JSON/Protobuf is the pragmatic middle ground — universal browser support, works through proxies, and the frame format is extensible. At WhatsApp scale, they use a custom binary protocol over TCP (XMPP-derived) for maximum efficiency.
Data Model
The data model for a messaging app is driven by one dominant access pattern: "fetch the last N messages in conversation X, ordered by time." This is a range query on a time-ordered sequence within a partition — the exact workload was designed for. Every schema decision flows from this access pattern.
Primary table: messages
The messages table is the heart of the system. It's partitioned by conversation_id so all messages in a conversation live on the same node — enabling efficient range scans. Within each partition, messages are sorted by a monotonically increasing sequence number (not timestamp — clocks lie).
CREATE TABLE messages (
conversation_id UUID, -- partition key
sequence BIGINT, -- clustering column (monotonic per conversation)
message_id UUID, -- globally unique, client-generated (idempotency)
sender_id UUID,
content_type TEXT, -- 'text', 'image', 'video', 'audio', 'document'
content_body TEXT, -- encrypted text or media URL
content_metadata FROZEN<MAP<TEXT, TEXT>>, -- thumbnail_url, file_size, duration, etc.
reply_to_seq BIGINT, -- NULL if not a reply
status TEXT, -- 'sent', 'delivered', 'read' (per-conversation aggregate)
created_at TIMESTAMP,
deleted_at TIMESTAMP, -- soft delete (message deleted for everyone)
PRIMARY KEY ((conversation_id), sequence)
) WITH CLUSTERING ORDER BY (sequence DESC)
AND default_time_to_live = 2592000; -- 30-day TTL (WhatsApp model)
-- Why DESC: "fetch latest 50 messages" is the hot query.
-- Cassandra reads clustering columns in order — DESC means latest first.
🔑 Why sequence number, not timestamp?
Timestamps from client devices are unreliable — clock skew, timezone bugs, and deliberate manipulation. A server-assigned sequence number per conversation guarantees total ordering within that conversation. The server increments the sequence atomically when it accepts a message. Clients use this sequence for pagination, sync, and gap detection.
Conversations table
Each conversation (1:1 or group) has metadata stored separately. This table is queried when rendering the inbox — "show me all my conversations with the latest message preview, sorted by recency."
CREATE TABLE conversations (
conversation_id UUID,
type TEXT, -- '1:1' or 'group'
name TEXT, -- NULL for 1:1, group name for groups
avatar_url TEXT,
created_by UUID,
created_at TIMESTAMP,
last_message_seq BIGINT, -- latest sequence (for sync)
last_message_preview TEXT, -- "Alice: Hey, are you free?" (denormalized)
last_message_at TIMESTAMP, -- for inbox sorting
PRIMARY KEY ((conversation_id))
);
-- User's inbox: which conversations does user X belong to?
CREATE TABLE user_conversations (
user_id UUID, -- partition key
last_activity_at TIMESTAMP, -- clustering (for inbox sort)
conversation_id UUID,
unread_count INT,
muted BOOLEAN,
pinned BOOLEAN,
PRIMARY KEY ((user_id), last_activity_at, conversation_id)
) WITH CLUSTERING ORDER BY (last_activity_at DESC);
-- Why denormalize last_message_preview?
-- The inbox query needs it. Joining messages table for every conversation
-- in the inbox would be N+1 queries. Denormalize on write, read once.
Conversation members
For group messaging, we need to know who's in a conversation (for fan-out) and what conversations a user belongs to (for inbox). This is a classic many-to-many relationship, modeled as two denormalized tables for different access patterns.
-- "Who is in this conversation?" (needed for message fan-out)
CREATE TABLE conversation_members (
conversation_id UUID, -- partition key
user_id UUID, -- clustering column
role TEXT, -- 'admin', 'member'
joined_at TIMESTAMP,
last_read_seq BIGINT, -- per-user read cursor (for unread count)
PRIMARY KEY ((conversation_id), user_id)
);
-- For a 1:1 conversation: exactly 2 members
-- For a group: up to 100K members (Telegram-scale)
-- For groups > 1000 members, this partition gets large.
-- Mitigation: bucket by (conversation_id, bucket_id) where bucket = user_id % 10
Connection routing table (Redis)
This is the real-time routing layer — not persisted to disk, lives entirely in Redis. When a message needs to be delivered, the system looks up which WebSocket server the recipient is connected to.
Key pattern: user:{user_id}:connections
Type: Hash (supports multi-device)
HSET user:alice:connections
"dev_iphone_14" "ws-server-042:conn_8a3b"
"dev_macbook" "ws-server-117:conn_2f9c"
TTL: 60 seconds (refreshed by heartbeat)
If TTL expires → user is offline → route to push notification
Additional keys:
user:{user_id}:presence → "online" | "offline" | timestamp (last_seen)
user:{user_id}:typing:{conversation_id} → TTL 5s (auto-expires)
Why Redis?
→ Sub-millisecond lookups for routing decisions
→ TTL handles cleanup automatically (no stale connections)
→ Pub/Sub for cross-server message routing
Message deduplication table
Clients retry on network failure. Without deduplication, retries create duplicate messages. A lightweight dedup table (or bloom filter) catches replays.
Option A: Redis SET with TTL
SETEX dedup:{message_id} 86400 "1"
→ Check before processing: if EXISTS → already processed, return cached ack
→ 24-hour TTL covers all reasonable retry windows
Option B: Cassandra table (for durability)
CREATE TABLE message_dedup (
message_id UUID PRIMARY KEY,
processed_at TIMESTAMP
) WITH default_time_to_live = 86400;
Choice: Redis for speed (sub-ms check on every message).
Cassandra as fallback if Redis loses the key (rare, acceptable).
Why Cassandra over Postgres?
The access pattern is the deciding factor. Messaging is a write-heavy, partition-scoped, time-ordered workload — exactly what Cassandra's LSM-tree storage engine and partition-based distribution are optimized for.
| Criteria | Cassandra / ScyllaDB | Postgres |
|---|---|---|
| Write throughput | ✅ Millions/sec (append-only LSM) | ❌ ~50K/sec per node (B-tree, WAL sync) |
| Partition scan | ✅ O(1) partition locate + sequential read | ⚠️ Requires index; large tables degrade |
| Horizontal scaling | ✅ Linear — add nodes, data rebalances | ❌ Requires manual sharding or Citus |
| TTL / auto-expiry | ✅ Native per-row TTL with compaction | ❌ Requires cron job + DELETE queries |
| Multi-region | ✅ Native multi-DC replication | ❌ Complex (logical replication, conflict resolution) |
| Transactions | ❌ No multi-partition transactions | ✅ Full ACID |
| Ad-hoc queries | ❌ Must model for access patterns | ✅ Flexible SQL joins |
For messaging, we don't need transactions across conversations or ad-hoc joins. Every query is scoped to a single conversation partition. Cassandra wins on every axis that matters here.
🎯 Partition sizing rule of thumb
Keep Cassandra partitions under 100MB. A conversation with 10K messages × 500 bytes = 5MB — well within limits. Even a hyperactive group chat with 1M messages = 500MB, which is too large. For mega-groups, bucket messages by time window: PRIMARY KEY ((conversation_id, month_bucket), sequence).
💡 The schema tells the story
In an interview, drawing these three tables — messages (partitioned by conversation), user_conversations (partitioned by user), and connection routing (Redis) — immediately communicates that you understand the three core access patterns: "fetch messages in a chat", "show my inbox", and "route this message to the right server."
High-Level Architecture
The architecture splits into four independent subsystems, each with its own scaling axis and failure domain. This separation is critical because a messaging system has fundamentally different requirements for real-time delivery (sub-500ms), offline delivery (minutes to hours), media handling (large files, async processing), and presence (ephemeral, best-effort). Coupling them would mean a media upload spike could delay message delivery — unacceptable.
Path 1: Online message delivery (the hot path)
When User A sends a message to User B who is currently online, the message flows through the system in under 500ms. The key insight: the message never touches a queue or disk on the hot path for online recipients. It goes directly from sender's WebSocket server → routing lookup → recipient's WebSocket server.
Sender
Client app
WS Server A
Sender's connection
Chat Service
Validate + persist + route
Redis Routing
user → server lookup
WS Server B
Recipient's connection
Recipient
Delivered!
The Chat Service is the brain. It validates the message, assigns a sequence number, persists to Cassandra (async — doesn't block delivery), looks up the recipient's WebSocket server in Redis, and forwards the message. For 1:1 messages, this is a single Redis lookup + single server-to-server hop. Total latency: ~50-100ms server-side.
Path 2: Offline delivery (push notifications)
When the recipient is offline (no active WebSocket connection), the message is persisted to Cassandra and a push notification is triggered. When the recipient comes back online, the sync protocol delivers all missed messages.
Chat Service
Recipient offline
Cassandra
Message persisted
Push Queue
Kafka topic
Push Service
APNs / FCM
Device
Notification shown
The push notification contains only a preview (sender name + truncated text) — never the full message content. The full message is fetched when the user opens the app and the sync protocol runs. This is both a security measure (push notifications traverse Apple/Google servers) and a bandwidth optimization.
Path 3: Media upload and delivery
Media files (images, videos, documents) never flow through the WebSocket connection or the Chat Service. They're uploaded directly to object storage via a separate upload service, and only the resulting URL is sent as a message. This keeps the real-time path lightweight.
Sender
Upload file
Upload Service
Chunked upload
S3
Object storage
Processor
Thumbnail + transcode
CDN
Edge delivery
The flow: (1) client requests a from the Upload Service, (2) client uploads directly to S3 (chunked, resumable), (3) S3 triggers a Lambda/worker to generate thumbnails and compress, (4) client sends a message with the media URL via WebSocket, (5) recipient downloads media from CDN on demand.
Path 4: Presence and typing indicators (ephemeral)
Presence (online/offline/last-seen) and typing indicators are fundamentally different from messages: they're ephemeral, best-effort, and never persisted long-term. They flow through the same WebSocket connection but bypass the message persistence layer entirely.
User A
Starts typing
WS Server
Receive indicator
Presence Service
In-memory state
WS Server B
Forward to recipient
User B
Shows 'typing...'
Component responsibilities
Each component has a single, clear responsibility. If you can't describe what it does in one sentence, it's doing too much.
WebSocket Gateway
Maintains persistent connections. Authenticates on connect. Routes incoming frames to Chat Service. Pushes outgoing messages to connected clients. Stateful — holds connection state in memory.
Chat Service
The routing brain. Validates messages, assigns sequence numbers, persists to Cassandra, looks up recipient connections in Redis, and forwards messages to the correct WebSocket server. Stateless — horizontally scalable.
Connection Registry (Redis)
Maps user_id → (server_id, connection_id) for every online user. Sub-millisecond lookups. TTL-based cleanup. The source of truth for 'where is this user connected right now?'
Message Store (Cassandra)
Durable message persistence. Partitioned by conversation_id, sorted by sequence. Handles 3.5M writes/sec across the cluster. 30-day TTL for automatic cleanup.
Push Notification Service
Sends notifications via APNs (iOS) and FCM (Android) for offline users. Rate-limited to avoid spamming. Batches multiple messages into single notifications. Never sends message content — only previews.
Media Upload Service
Handles chunked, resumable file uploads. Generates pre-signed S3 URLs. Triggers async processing (thumbnails, compression, virus scan). Returns media URL for embedding in messages.
Presence Service
Tracks online/offline/last-seen state. Purely in-memory (Redis). Publishes status changes to subscribers. Typing indicators flow through here — never persisted, never queued.
Sync Service
Handles the 'catch-up' protocol when a device reconnects. Compares client's last-known sequence per conversation with server state. Pushes missed messages in order. Handles multi-device sync coordination.
Why four separate paths matter
Each path has fundamentally different characteristics that demand different infrastructure:
- Online delivery — latency-critical (<500ms), stateful (WebSocket), scales on connection count
- Offline delivery — latency-tolerant (seconds to minutes), stateless, scales on push notification throughput
- Media — bandwidth-heavy (PBs/day), async, scales on storage and CDN capacity
- Presence — ephemeral, best-effort, scales on event rate (50M/sec for typing)
Coupling them means: a media upload spike exhausts bandwidth → WebSocket heartbeats timeout → users appear offline → presence becomes unreliable → typing indicators break. Separation prevents cascading failures across fundamentally different workloads.
Total budget: 500ms end-to-end (sender tap → recipient screen)
Sender side:
0ms ─ User taps send
5ms ─ Client encrypts (E2E), constructs frame
10ms ─ WebSocket frame sent over network
Server side:
15ms ─ WS Gateway receives frame, validates auth
20ms ─ Chat Service: dedup check (Redis EXISTS) → 0.5ms
25ms ─ Chat Service: assign sequence (Redis INCR) → 0.5ms
30ms ─ Chat Service: persist to Cassandra (async, non-blocking)
35ms ─ Chat Service: lookup recipient connection (Redis HGET) → 0.5ms
40ms ─ Chat Service: forward to recipient's WS server (internal RPC)
Recipient side:
45ms ─ WS Gateway B pushes frame to recipient's connection
50ms ─ Network transit to recipient device
55ms ─ Client decrypts, renders message
60ms ─ Client sends delivery receipt back
Total on happy path: ~60ms (well under 500ms budget)
─────────────────────────────────────────────────────
Worst case (cross-region, Cassandra write blocks): ~200ms
Still under budget with 2.5× headroom.
🔑 The async persistence trick
Notice that Cassandra persistence is async and non-blocking on the delivery path. The message is forwarded to the recipient BEFORE the write is confirmed. If Cassandra is slow or temporarily down, delivery still happens in real-time. The message is buffered and retried for persistence. This is safe because: (1) the recipient has the message, (2) the sender got an ack, (3) if Cassandra write ultimately fails, the message still exists on both client devices.
💡 What to say to the interviewer
"The architecture separates real-time delivery from persistence. Online messages are routed directly server-to-server via Redis lookups — Cassandra is on the write path but not the latency path. This means a database slowdown doesn't affect delivery latency, only durability — and we have client-side message state as a fallback."
WebSocket Connection Management
Managing 50 million concurrent WebSocket connections is the defining engineering challenge of a messaging system. Unlike stateless HTTP services where any server can handle any request, WebSocket connections are stateful — each connection is pinned to a specific server. This creates three hard problems: routing (which server holds User X?), failover (what happens when a server dies with 500K connections?), and deployment (how do you roll out new code without disconnecting everyone?).
Connection routing architecture
Every active connection is registered in a centralized . When a message needs to be delivered, the Chat Service does a single Redis lookup to find the recipient's server, then makes a direct server-to-server RPC. No message bus, no pub/sub fan-out — just a point-to-point call.
On WebSocket connect:
HSET user:{user_id}:connections {device_id} "{server_id}:{conn_id}"
EXPIRE user:{user_id}:connections 60 // refreshed by heartbeat
On message delivery:
connections = HGETALL user:{recipient_id}:connections
// Returns: { "iphone": "ws-042:conn_8a3b", "macbook": "ws-117:conn_2f9c" }
// → Forward message to BOTH servers (multi-device)
On disconnect:
HDEL user:{user_id}:connections {device_id}
// If hash is now empty → user is fully offline
Heartbeat (every 30s):
EXPIRE user:{user_id}:connections 60
// If client misses 2 heartbeats (60s), key auto-expires → offline
Server-to-server message forwarding
When User A (on ws-server-042) sends a message to User B (on ws-server-117), the Chat Service needs to get the message from server 042 to server 117. There are two approaches, and the choice depends on scale:
| Approach | How it works | Pros | Cons |
|---|---|---|---|
| Direct RPC (gRPC) | Chat Service calls ws-server-117 directly via internal gRPC | Lowest latency (~1ms). Simple. No intermediate broker. | Requires service discovery. N² potential connections between servers. |
| Redis Pub/Sub | Publish to channel 'ws-server-117'. That server subscribes to its own channel. | Decoupled. No direct server-to-server connections. | Extra hop through Redis. Pub/Sub is fire-and-forget (no persistence). |
| Kafka per-server topic | Produce to topic 'ws-server-117-inbox'. Server consumes its topic. | Durable. Handles server restarts gracefully. | Higher latency (~5-10ms). Overkill for online delivery. |
The pragmatic choice: direct gRPC for online delivery (lowest latency) with Kafka as fallback for when the target server is temporarily unreachable. If the gRPC call fails (server overloaded, network blip), the message is produced to a per-server Kafka topic. The server consumes from its topic on recovery.
Handling server failures
When a WebSocket server crashes, 500K users instantly lose their connections. The system must handle this gracefully without losing messages or creating a thundering herd on reconnection.
1. DETECTION (5-10 seconds)
Health checker (separate service) pings each WS server every 5s.
3 missed pings → declare server dead.
Alternative: server registers with TTL in Redis; expiry = dead.
2. CLEANUP (immediate)
Bulk-delete all connection entries for the dead server:
SCAN for keys where value contains "ws-server-042"
→ Mark all those users as "offline" in presence
→ Any in-flight messages for those users → route to offline inbox
3. RECONNECTION (client-driven, staggered)
Clients detect disconnect (WebSocket onclose event).
Each client waits: random(0, 5000ms) + exponential_backoff
→ Prevents thundering herd (500K clients reconnecting simultaneously)
→ Load balancer distributes across remaining healthy servers
4. SYNC (on reconnect)
Client sends last-known sequence per conversation.
Sync Service pushes missed messages.
→ No messages lost — they're in Cassandra, just not yet delivered.
5. REBALANCING (background)
After reconnection storm settles, connections are unevenly distributed.
Gradual rebalancing: periodically ask 5% of connections on overloaded
servers to reconnect (server sends "please reconnect" frame).
🔑 The thundering herd problem
If 500K clients all reconnect simultaneously to the remaining 199 servers, each server gets ~2,500 new connections/sec on top of its existing 500K. The connection handshake (TLS + auth + sync) is expensive — ~10ms each. Without staggered reconnection, the remaining servers could be overwhelmed. Client-side jitter (random delay before reconnect) is the standard mitigation. WhatsApp uses exponential backoff with jitter: min(30s, base × 2^attempt + random_ms(0, 1000)).
Detecting dead connections (bad → good → great)
A WebSocket may be technically "open" but functionally dead — the user switched networks, entered a tunnel, or their device went to sleep. We won't know until we try to send a message and it times out. TCP keepalives can take minutes to detect a dead connection. For a real-time chat app, that's unacceptable — users would stare at a "connected" app that's actually dead, missing messages the whole time.
| Approach | How it works | Detection time | Verdict |
|---|---|---|---|
| ❌ Rely on TCP timeouts | Do nothing. When the connection dies, TCP eventually times out and the socket closes. Client reconnects and syncs. | 2-10 minutes (TCP keepalive defaults) | Far too slow. Users miss messages for minutes without knowing. Not acceptable for real-time chat. |
| ⚠️ ACK timeouts with server-side retry | When server delivers a message, wait for client ACK. If no ACK within 1-2s, retry. After 3 failures, close connection and force reconnect. | Seconds (but only when sending) | Only detects failures when actively sending. If the connection dies during a quiet period, we won't notice until the next message arrives. Could be hours. |
| ✅ Application-level heartbeats | Server sends ping every 30s. Client must respond with pong within 5s. If missed, close connection. Client reconnects and syncs missed messages. | 35 seconds worst case (30s interval + 5s timeout) | Guaranteed upper bound on detection time regardless of message activity. Catches dead connections even during quiet periods. |
Server → Client: PING (every 30 seconds)
Client → Server: PONG (must arrive within 5 seconds)
If PONG not received:
1. Server marks connection as "suspect"
2. Server sends one more PING (retry)
3. If still no PONG after 5s → connection is dead
4. Server closes WebSocket, removes from Redis registry
5. Any pending messages → route to offline inbox
6. Client detects close → reconnects with jittered backoff → syncs
Overhead calculation:
50M connections × 1 ping/30s = 1.67M ping-pong exchanges/sec
Each ping/pong is ~2 bytes payload → ~3.3 MB/s total
→ Trivial overhead. Well worth the guaranteed detection time.
Why not just use WebSocket protocol-level ping/pong?
→ Browser WebSocket API doesn't expose protocol-level pings to JS
→ Application-level heartbeats give us control over timing and payload
→ We can piggyback useful data on heartbeats (sequence numbers, presence)
The heartbeat approach is what WhatsApp, Telegram, and Signal all use in production. The 30-second interval is a sweet spot: frequent enough to detect dead connections quickly, infrequent enough to not drain mobile batteries. Some apps use adaptive intervals — shorter when the user is actively chatting, longer when idle.
Graceful deployments (zero-downtime)
Rolling deployments of WebSocket servers require special handling. You can't just kill a server — you need to drain its connections gracefully.
1. Mark server as "draining" in service registry
→ Load balancer stops sending NEW connections to this server
→ Existing connections continue working
2. Send "reconnect" frame to clients in batches
→ 10% of connections every 30 seconds
→ Each client reconnects to a different (healthy) server
→ Total drain time: ~5 minutes for 500K connections
3. Wait for all connections to close (or timeout after 10 min)
→ Remaining stubborn connections get force-closed
4. Shut down server, deploy new version, restart
→ Server rejoins the pool, load balancer sends new connections
Why not just restart?
→ 500K simultaneous disconnects = thundering herd
→ Gradual drain = smooth redistribution across the fleet
→ Users experience a brief reconnect (~1s), not a service outage
Connection affinity vs load balancing
The load balancer must route WebSocket upgrade requests intelligently. Pure round-robin creates a problem: if User A reconnects, they might land on a different server, and any in-flight messages routed to the old server are lost (until the routing table updates).
| Strategy | How | Trade-off |
|---|---|---|
| Hash-based affinity | hash(user_id) % N_servers → always same server | Predictable routing, but server failure redistributes unevenly. Adding servers remaps many users. |
| Consistent hashing | User lands on nearest server on hash ring | Minimal disruption on server add/remove. Slightly more complex routing logic. |
| Random + registry | Any server, register in Redis immediately | Simplest. Works because Redis lookup is the source of truth anyway. Small window of stale routing on reconnect. |
The pragmatic choice: random assignment + Redis registry. The routing table in Redis is the source of truth — not the load balancer. The load balancer just needs to distribute connections evenly. The Chat Service always checks Redis for the current location, so even if a user moves servers, messages route correctly within milliseconds of the new registration.
Per-server resource budget
Target: 500K concurrent connections per server
Memory breakdown:
TCP buffers (kernel) : 500K × 8KB = 4 GB
TLS state : 500K × 5KB = 2.5 GB
Application state : 500K × 4KB = 2 GB (user_id, device, auth, buffers)
Message routing cache : 1 GB (hot user→server mappings)
Go/Rust runtime : 2 GB (GC overhead, goroutine stacks)
OS + misc : 2.5 GB
─────────────────────────────────────────────────
Total : ~14 GB (of 64 GB available)
Headroom : 50 GB for burst buffers and message queuing
CPU:
Heartbeat processing : 500K / 30s = 16.7K heartbeats/sec → trivial
Message routing : ~50K messages/sec per server → ~2 cores
TLS encrypt/decrypt : ~3 cores (AES-NI hardware acceleration)
Frame parsing : ~1 core
─────────────────────────────────────────────────
Total : ~7 cores active (of 32 available)
Bottleneck : File descriptors (ulimit -n 1048576) and memory, not CPU
💡 The key insight for interviews
WebSocket servers are memory-bound and file-descriptor-bound, not CPU-bound. A server handling 500K idle connections uses almost no CPU — it's just holding state. The scaling limit is RAM for connection state and the OS limit on open file descriptors. This is why languages with lightweight concurrency (Go goroutines, Rust async, Erlang processes) dominate this space — they minimize per-connection memory overhead.
Message Delivery & Ordering
Message ordering is the hardest correctness problem in a messaging system. Users expect messages to appear in the order they were sent — but in a distributed system with multiple servers, network partitions, and clock skew, "order" is not a trivial concept. The solution is a per-conversation — a monotonically increasing counter scoped to each conversation, assigned by the server at the moment it accepts a message.
How to order messages (bad → good → great)
Message ordering seems trivial — "just use timestamps." But in a distributed system with millions of devices, each with its own clock, ordering is one of the hardest correctness problems. Let's walk through the approaches:
| Approach | How it works | Problem | Verdict |
|---|---|---|---|
| ❌ Client timestamps | Each message carries the sender's device clock time. Display messages sorted by this timestamp. | Client clocks are unreliable — timezone bugs, NTP drift, deliberate manipulation. Bob's phone is 30s behind → his reply appears BEFORE Alice's question. | Broken. Users see messages in wrong order. Different clients see different orderings. Unacceptable. |
| ⚠️ Server NTP timestamps | Server stamps each message with its own clock time (synced via NTP). All servers agree on time within ~10ms. | Works for most cases, but: two messages arriving within the same millisecond have arbitrary order. NTP can drift. No gap detection possible (timestamps aren't sequential). | Acceptable for display, but can't detect missed messages. WhatsApp's early approach. Messages occasionally 'pop in' above newer ones. |
| ✅ Per-conversation sequence numbers | Server assigns a monotonically increasing integer per conversation via atomic Redis INCR. Sequence 101, 102, 103... Total order guaranteed. | Requires a coordination point (Redis) per conversation. Single serialization point. | Best. Total order within conversation. Gap detection is trivial (missing sequence = missed message). Pagination is clean. Redis INCR handles 100K ops/sec per key — far above any conversation's throughput. |
The key insight: server NTP timestamps are fine for display (showing "3:42 PM" next to a message), but sequence numbers are required for ordering and sync. We use both: sequence for ordering/gap-detection, timestamp for display.
The ordering guarantee
We guarantee total order within a conversation but NOT across conversations. This is a deliberate trade-off: global ordering would require a single serialization point (bottleneck), while per-conversation ordering can be parallelized across millions of independent counters.
Per-conversation sequence counter (Redis):
Key: seq:{conversation_id}
Operation: INCR seq:{conversation_id} → returns next sequence
Example flow:
Alice sends "Hey" to conv_abc → sequence 101
Bob sends "Hi!" to conv_abc → sequence 102
Alice sends "Lunch?" to conv_abc → sequence 103
Even if Alice's "Lunch?" arrives at the server before Bob's "Hi!"
(due to network jitter), the server assigns sequences in arrival order.
The server is the single source of truth for ordering within a conversation.
Why not timestamps?
Alice's phone clock: 12:00:01.000 (correct)
Bob's phone clock: 12:00:00.500 (30 seconds behind)
→ Bob's message would appear BEFORE Alice's even though Alice sent first.
→ Server-assigned sequence eliminates client clock dependency entirely.
Delivery guarantees: at-least-once with deduplication
The system provides semantics with client-side deduplication. This means: the server will retry delivery until acknowledged, but the client must handle duplicates gracefully using the message ID.
SENDER SIDE:
1. Client sends message with client-generated UUID (msg_7f3a9b2c)
2. Client starts retry timer (5 seconds)
3. If no server ACK within 5s → resend same frame (same msg_id)
4. Server deduplicates by msg_id (Redis SETNX dedup:{msg_id} 86400)
5. Server ACKs with sequence number → client stops retrying
6. Client marks message as "sent" (single checkmark ✓)
RECIPIENT SIDE:
1. Server pushes message to recipient's WebSocket
2. Recipient client sends delivery receipt (ACK)
3. If no ACK within 10s → server retries push (up to 3 times)
4. If still no ACK → message stays in "pending delivery" state
5. On next sync (reconnect), client pulls pending messages
6. Client deduplicates by msg_id before rendering
DEDUPLICATION:
Client maintains a local set of seen message IDs (last 10K messages).
If a message arrives with an ID already in the set → silently drop.
This handles: network retries, sync replays, server-side redelivery.
Detecting missed messages (bad → good → great)
Messages can be lost in transit — the WebSocket frame drops silently, the server-to-server RPC times out, or a network partition causes a delivery failure. The server has already persisted the message to Cassandra (durability is guaranteed), but the client doesn't have it yet. How do we detect and recover from this?
| Approach | How it works | Detection time | Verdict |
|---|---|---|---|
| ❌ Do nothing (hope for the best) | Assume WebSocket delivery always works. If a message is lost, the user never sees it until they restart the app. | Never (until app restart) | Unacceptable. Users would permanently miss messages in active conversations. Silent data loss. |
| ⚠️ Periodic polling | Client polls the server every 30-60s: 'do I have any undelivered messages?' Server checks inbox/Cassandra and pushes any missed messages. | 30-60 seconds | Works but adds load: 50M clients × 1 poll/30s = 1.67M queries/sec just for sync checks. Latency is also poor — user waits up to 60s for a missed message. |
| ✅ Sequence numbers with gap detection | Each message has a per-conversation sequence number. Client tracks last-seen sequence. If message #5 arrives but last seen was #3, client knows #4 is missing and requests it immediately. | Instant (on next message arrival) | Zero additional load during quiet periods. Instant detection when a new message arrives. But: if the chat goes quiet after the missed message, gap isn't detected until next activity. |
| ✅✅ Sequence piggybacked on heartbeats | Server includes the user's latest global sequence in every heartbeat ping. Client compares with local state. If behind, immediately syncs. | ≤ 30 seconds (one heartbeat interval) | Best of both worlds: catches missed messages even in quiet chats, within one heartbeat cycle. Minimal overhead since heartbeats already exist. |
The production-grade approach combines the last two: sequence-based gap detection catches misses instantly during active conversations, and heartbeat-piggybacked sequences catch misses during quiet periods. Periodic polling serves as a final backstop (every 5 minutes) for defense-in-depth.
Gap detection and recovery (the implementation)
Clients track the last-seen sequence number per conversation. If a message arrives with sequence N+2 but the client only has up to N, there's a gap (sequence N+1 is missing). The client requests the missing messages from the server.
Client state per conversation:
last_seen_sequence: 103
Incoming message arrives:
sequence: 106 (gap! missing 104, 105)
Client action:
1. Buffer message 106 (don't render yet — would break visual order)
2. Request gap fill: GET /api/v1/conversations/{id}/messages?after_sequence=103&before_sequence=106
3. Server returns messages 104, 105
4. Client inserts 104, 105, 106 in order
5. Update last_seen_sequence to 106
Why gaps happen:
→ Server delivered 104 and 105 but WebSocket dropped those frames
→ Network partition caused some messages to route via offline inbox
→ Multi-device: another device ACKed 104/105 but this device missed them
Optimization:
If gap is small (1-2 messages), wait 500ms — they might arrive out of order.
If gap persists after 500ms, trigger gap fill request.
Message states and transitions
Each message progresses through a well-defined state machine. The states are visible to the user as checkmarks (WhatsApp-style): one gray check (sent), two gray checks (delivered), two blue checks (read).
States:
PENDING → Client created, not yet ACKed by server
SENT → Server ACKed (sequence assigned) — ✓
DELIVERED → Recipient device received — ✓✓
READ → Recipient opened conversation — ✓✓ (blue)
FAILED → Server rejected (content too long, user blocked, etc.)
Transitions:
PENDING → SENT : Server ACK received
PENDING → FAILED : Server NACK received (or timeout after 30s)
SENT → DELIVERED : Delivery receipt from ANY of recipient's devices
DELIVERED → READ : Read receipt from recipient
Rules:
→ States only move forward (DELIVERED never goes back to SENT)
→ DELIVERED means at least ONE device received it
→ READ is per-conversation, not per-message (read_up_to_sequence)
→ In groups: DELIVERED = all members received; READ = all members read
(WhatsApp shows individual read status in group info)
For groups:
→ Track delivery/read per member (conversation_members.last_read_seq)
→ Show aggregate: "delivered to 45/50 members"
→ Individual status available on tap (like WhatsApp message info)
Handling concurrent senders in the same conversation
When Alice and Bob both send messages to the same group at the same instant, both hit the sequence counter simultaneously. Redis INCR is atomic — it serializes concurrent increments and returns unique values. This is the single serialization point per conversation.
| Approach | Throughput | Ordering guarantee | Trade-off |
|---|---|---|---|
| Redis INCR (chosen) | ~100K INCR/sec per key | Total order within conversation | Single Redis node per conversation. Hot conversations bottleneck at ~100K msg/sec (far above any real group). |
| Snowflake ID | Unlimited (no coordination) | Approximate order (timestamp-based) | No total order guarantee. Two messages at same millisecond have arbitrary order. |
| Lamport timestamp | Unlimited (no coordination) | Causal order only | Concurrent messages are unordered. Requires conflict resolution. |
| Database sequence | ~10K/sec (Cassandra LWT) | Total order | Too slow. Lightweight transactions in Cassandra are expensive. |
Redis INCR at 100K operations/sec per key is more than sufficient. The most active group chat in the world won't exceed 1K messages/sec. The bottleneck is never the sequence counter — it's the fan-out to group members.
🔑 Why not just use Kafka ordering?
Kafka guarantees ordering within a partition. You could partition by conversation_id and get ordering "for free." But Kafka adds 5-10ms latency per message (produce + consume), which blows the real-time delivery budget. Kafka is used for offline delivery and persistence — not the hot path. The hot path uses direct server-to-server routing with Redis-based sequence assignment.
Retry and timeout strategy
Client → Server (send message):
Retry interval: 1s, 2s, 4s, 8s, 16s (exponential backoff)
Max retries: 5
After 5 failures: mark as FAILED, show error to user
Idempotency: same msg_id on every retry → server deduplicates
Server → Recipient (deliver message):
If recipient online: push via WebSocket, wait 10s for ACK
If no ACK: retry push 3 times (1s, 2s, 4s)
If still no ACK: assume connection is stale, mark offline
Message goes to offline inbox → delivered on next sync
Server → Cassandra (persist):
Retry with exponential backoff: 100ms, 200ms, 400ms
Max retries: 10 (Cassandra is eventually available)
If all retries fail: buffer in local WAL, retry on background thread
Message is already delivered to recipient — persistence is best-effort
(client devices are the ultimate source of truth for E2E encrypted messages)
Push notification (offline):
Single attempt to APNs/FCM
If rejected (invalid token): mark device token as stale
If rate-limited: exponential backoff per device
No retry for transient failures — next message will trigger a new push
💡 The interview insight
"We achieve effectively-once delivery through at-least-once semantics plus client-side deduplication. The message ID is client-generated (UUID), making retries idempotent. The server deduplicates on write, and the client deduplicates on render. This is simpler and more performant than distributed exactly-once transactions, which would require two-phase commits across the delivery pipeline."
Group Messaging & Fan-out
Group messaging introduces the : a single message sent to a group of N members must be delivered N times. For a 10-person family group, this is trivial. For a 200,000-member Telegram channel, it's a distributed systems challenge that can generate millions of delivery operations per second. The strategy must adapt based on group size.
Fan-out strategy by group size
There is no single correct fan-out strategy. The right approach depends on the group size, and a production system uses different strategies for different tiers:
| Group size | Strategy | How it works | Why |
|---|---|---|---|
| Small (2-100) | Fan-out on write | Server immediately pushes message to each member's WebSocket (or offline inbox) | Low member count → fan-out is cheap. Delivery is instant. Simple implementation. |
| Medium (100-10K) | Fan-out on write (batched) | Same as above but members are processed in batches of 500 with backpressure | Prevents a single group message from monopolizing server resources. Adds ~100ms latency for last batch. |
| Large (10K-200K) | Hybrid: fan-out on write for online, pull for offline | Push to currently-online members. Offline members pull on reconnect via sync. | Pushing to 200K offline inboxes is wasteful — most won't open the app for hours. |
| Broadcast channels | Fan-out on read | Message stored once. Each member fetches on open. Push notification sent to subset. | 1M+ subscribers. Writing 1M inbox entries per message is unsustainable. |
Small group fan-out (the common case)
90% of conversations are 1:1 or small groups (<20 members). For these, the fan-out is straightforward and happens synchronously in the Chat Service:
Message arrives from Alice to group "Family" (8 members):
1. Chat Service assigns sequence: INCR seq:conv_family → 4821
2. Persist to Cassandra: INSERT INTO messages (conv_family, 4821, ...)
3. Fetch members: SELECT user_id FROM conversation_members WHERE conversation_id = conv_family
→ Returns: [alice, bob, carol, dave, eve, frank, grace, henry]
4. Remove sender: [bob, carol, dave, eve, frank, grace, henry] (7 recipients)
5. For each recipient, lookup connection:
HGETALL user:bob:connections → {"iphone": "ws-042:conn_1a"}
HGETALL user:carol:connections → {} (offline!)
HGETALL user:dave:connections → {"android": "ws-117:conn_2b", "web": "ws-089:conn_3c"}
...
6. Online members: forward message to their WebSocket servers (parallel gRPC calls)
7. Offline members: enqueue push notification
Total time for 7 recipients: ~10ms (parallel lookups + parallel forwards)
All members receive within ~50ms of each other.
Large group fan-out (the hard case)
For groups with 10K+ members, synchronous fan-out in the Chat Service would block for too long. Instead, the message is accepted immediately (ACK to sender) and fan-out happens asynchronously via a dedicated Fan-out Service consuming from Kafka.
Message arrives from admin to "Tech News" channel (150K members):
1. Chat Service: assign sequence, persist to Cassandra, ACK sender → 30ms
2. Chat Service: produce to Kafka topic "group-fanout" → 5ms
Payload: { conversation_id, sequence, message_id, sender_id }
3. Fan-out Service consumes from Kafka:
a. Fetch member list in pages: 500 members per page
b. For each page:
- Batch-lookup connections: MGET user:{id}:connections for 500 users
- Partition into online (has connection) and offline (no connection)
- Online: batch-forward to WebSocket servers (grouped by server)
- Offline: batch-enqueue push notifications
- Apply backpressure: if downstream is slow, pause Kafka consumption
c. Track progress: store last-processed page in Redis
(enables resume on Fan-out Service crash)
4. Delivery timeline:
- First 500 members: delivered within 100ms
- All 150K members: delivered within 5-10 seconds
- Push notifications: within 30 seconds (APNs/FCM batching)
5. Optimization: prioritize online members
- Process online members first (they're waiting)
- Offline members can wait — they'll sync on reconnect anyway
The celebrity problem (hot groups)
When a celebrity with a 1M-member channel posts a message, the fan-out generates 1M delivery operations. If they post 10 messages in a minute, that's 10M operations. This is the messaging equivalent of the "viral URL" problem.
Problem: Celebrity posts to 1M-member channel
→ 1M connection lookups
→ 1M WebSocket pushes (for online members)
→ 500K push notifications (for offline members)
→ All within seconds
Mitigation layers:
Layer 1 — Fan-out on read for mega-channels
Don't push to each member. Store message once.
When member opens the channel → pull latest messages via sync.
Push notification to a SAMPLE (10%) to drive engagement.
→ Reduces fan-out from 1M to ~100K (online members who have the channel open)
Layer 2 — Tiered delivery priority
Members who have the channel open (foreground): immediate push
Members who have the app open (background): push within 5s
Members who are offline: push notification within 60s
Members who muted the channel: no push, deliver on next sync only
Layer 3 — Batch and coalesce
If celebrity posts 5 messages in 10 seconds:
Don't send 5 separate pushes. Coalesce into one: "5 new messages in Tech News"
Reduces push notification volume by 5×
Layer 4 — Dedicated fan-out workers per hot channel
Detect hot channels (>10K members + >1 msg/min)
Assign dedicated Kafka partition + dedicated consumer group
Prevents hot channel from starving delivery to small groups
Routing channels: per-user vs per-chat (bad → good → great)
If you use a pub/sub system (Redis Pub/Sub, Kafka) for cross-server message routing, you need to decide: should the channel/topic be per-user or per-chat? This is a common interview question and the answer depends on the product's usage patterns.
| Approach | How it works | When it's good | When it breaks |
|---|---|---|---|
| ❌ Single global channel | All messages published to one channel. Every server subscribes and filters locally. | Never. This is a broadcast storm. | Every server receives every message for every user. At 3.5M msg/sec, each of 200 servers processes all 3.5M messages. Completely unscalable. |
| ⚠️ Per-chat channel | Each conversation has its own pub/sub channel. Servers subscribe to channels for all users they host. | Large groups (100+ members). One publish delivers to all members' servers at once. | A user in 500 chats requires 500 subscriptions per server. With 500K users per server × 500 chats = 250M subscriptions. Redis Pub/Sub melts. Also: 1:1 chats (90% of traffic) have exactly 2 subscribers — wasteful to maintain a dedicated channel. |
| ✅ Per-user channel | Each user has their own channel. To deliver a message, publish to each recipient's channel. Their server is subscribed. | 1:1 dominated apps (WhatsApp). One subscription per user per server. Simple. 500K subscriptions per server. | Large groups: sending to a 10K-member group means 10K publishes (one per user). But this is rare and handled by the async Fan-out Service anyway. |
| ✅✅ Adaptive (per-user + per-chat for large groups) | Default: per-user channels. For groups above a threshold (e.g., 25 members), create a per-chat channel. Servers subscribe to both. | Best of both worlds. Small chats use per-user (simple). Large groups use per-chat (efficient fan-out). | Complexity: must handle the transition when a group crosses the threshold. Brief dual-publish during transition. Edge cases on member join/leave. |
For WhatsApp-style apps (dominated by 1:1 and small groups), per-user channels are the right default. The adaptive approach is worth mentioning in an interview to show you understand the trade-off, but per-user alone handles 99% of the traffic pattern.
Group membership changes during fan-out
What happens if a member leaves the group while a message is being fanned out? Or a new member joins — should they see the message that was sent 2 seconds before they joined?
Rule 1: Snapshot membership at send time
When a message is accepted, the member list at that moment is the
delivery target. If Bob leaves 1 second later, he still gets the message
(it was sent while he was a member).
Rule 2: New members don't see pre-join messages (by default)
When Carol joins, her "visible_from_sequence" is set to the current
sequence. She only sees messages with sequence >= visible_from_sequence.
(Telegram shows history; WhatsApp doesn't. This is a product decision.)
Rule 3: Removed members stop receiving immediately
On removal: delete from conversation_members + revoke E2E keys.
Any in-flight messages already delivered are fine (they were a member).
No new messages will be delivered.
Implementation:
Fan-out Service checks membership at delivery time:
IF user is no longer a member AND message.sequence > user.left_at_sequence
THEN skip delivery (they left after this message was sent — don't deliver)
Edge case: user leaves and rejoins within the fan-out window
→ Use left_at_sequence and rejoined_at_sequence to determine visibility
Unread counts and last-read tracking
Each member has a last_read_seq per conversation. The unread count is simply: conversation.last_message_seq - member.last_read_seq. This is a single subtraction — no counting query needed.
Stored state:
conversation_members.last_read_seq = 4815 (Bob's last read in "Family")
conversations.last_message_seq = 4821 (latest message in "Family")
Unread count = 4821 - 4815 = 6 messages
When Bob opens the conversation:
UPDATE conversation_members SET last_read_seq = 4821
WHERE conversation_id = conv_family AND user_id = bob
→ Unread count becomes 0
When Bob sends a read receipt:
Server updates last_read_seq
Server forwards read receipt to sender(s) of unread messages
→ Sender sees blue checkmarks appear
Why this works at scale:
→ No COUNT(*) query needed — just arithmetic
→ Single row update on read (not N updates for N unread messages)
→ Works for groups of any size
→ Muted conversations: still track last_read_seq but don't show badge
🔑 Fan-out write amplification math
At 100B messages/day with average group size of 5 members:
Fan-out writes: 100B × 5 = 500B delivery operations/day
That's ~5.8M deliveries/sec. Most are 1:1 (fan-out = 1), so actual amplification is lower. But for a 200K-member channel with 100 messages/day, that's 20M deliveries just for one channel. This is why large channels MUST use fan-out-on-read.
💡 The interview framing
"I'd use a tiered fan-out strategy: synchronous push for small groups (under 100 members), async batched fan-out via Kafka for medium groups, and fan-out-on-read for broadcast channels over 10K members. The threshold is tunable — start conservative and lower it if the Fan-out Service becomes a bottleneck." This shows you understand that one-size-fits-all doesn't work.
Presence Service
Presence — showing who's online, who was "last seen 5 minutes ago," and who's currently typing — seems simple but generates enormous event volume at scale. With 500M DAU, every app-open and app-close is a presence event. Every keystroke in an active conversation is a typing event. The challenge is delivering this information in real-time without overwhelming the system with 50M+ events per second.
Presence states and transitions
A user's presence has three states, each with different visibility rules and update mechanisms:
States:
ONLINE → User has an active WebSocket connection
OFFLINE → No active connection (show "last seen" timestamp)
RECENTLY → Connection dropped <5 min ago (grace period before showing offline)
Transitions:
OFFLINE → ONLINE : WebSocket connection established + authenticated
ONLINE → RECENTLY : WebSocket disconnects (network switch, app backgrounded)
RECENTLY → ONLINE : Reconnects within 5 minutes (no visible change to contacts)
RECENTLY → OFFLINE : 5-minute grace period expires without reconnection
Why the RECENTLY state?
→ Users switch from WiFi to cellular constantly (brief disconnect)
→ App goes to background on iOS (WebSocket may close)
→ Without grace period, users would flicker online/offline every few minutes
→ WhatsApp uses ~5 min grace; Telegram uses ~30 seconds
Presence storage and propagation
Presence state lives entirely in Redis — it's ephemeral data that doesn't need durable storage. The key design decision is how to propagate status changes to interested parties (contacts who have the chat open).
Key: presence:{user_id}
Value: { "status": "online", "last_seen": 1716300000, "device": "iphone" }
TTL: 60 seconds (refreshed by heartbeat)
On connect:
SET presence:{user_id} '{"status":"online","last_seen":null}' EX 60
PUBLISH presence_updates:{user_id} "online"
On heartbeat (every 30s):
EXPIRE presence:{user_id} 60 // refresh TTL
On disconnect:
SET presence:{user_id} '{"status":"recently","last_seen":1716300000}' EX 300
// After 5 min TTL expires → key deleted → user is fully offline
On TTL expiry (no reconnect within grace period):
Key auto-deletes → presence lookup returns null → treat as offline
No explicit "offline" event needed — absence of key = offline
Who needs to know? (Subscription model)
Not every user cares about every other user's presence. Only users who currently have a conversation open with User X need real-time presence updates for X. This dramatically reduces the fan-out:
| Approach | How | Event volume | Trade-off |
|---|---|---|---|
| Broadcast to all contacts | Push status change to all 500 contacts | 500M users × 2 transitions × 500 contacts = 500B events/day | Completely unsustainable. Most contacts don't have the app open. |
| Subscribe on chat open (chosen) | When Alice opens chat with Bob, subscribe to Bob's presence | Only active conversations get updates. ~50M subscriptions at peak. | Slight delay on chat open (need to fetch current status). Acceptable. |
| Poll on demand | Client fetches presence when rendering contact list | Batch request: GET presence for 20 visible contacts | Not real-time. Acceptable for contact list, not for open chat. |
The hybrid approach: subscribe for open conversations (real-time via WebSocket) + poll for contact list (batch fetch, refresh every 30s). This reduces presence event volume from 500B/day to ~50M/day — a 10,000× reduction.
Typing indicators
Typing indicators are the most ephemeral signal in the system. They have zero durability requirements, zero delivery guarantees, and must never touch persistent storage. They're pure real-time signals that exist only in memory and on the wire.
Design principles:
1. NEVER persist to database or queue
2. NEVER retry on failure (if it doesn't arrive, that's fine)
3. Auto-expire after 5 seconds (no explicit "stopped typing" needed)
4. Throttle: max 1 typing event per 3 seconds per user per conversation
5. Only send to users who have the conversation OPEN (foreground)
Flow:
Alice starts typing in conv_abc:
1. Client sends typing frame: { type: "typing", conv: "conv_abc", action: "started" }
2. WS Server receives → forwards to Presence Service (in-memory)
3. Presence Service checks: who has conv_abc open right now?
→ Subscribers: [bob (ws-042), carol (ws-117)]
4. Forward typing indicator to Bob and Carol's WS servers
5. Bob and Carol's clients show "Alice is typing..."
6. After 5 seconds with no renewal → clients auto-hide indicator
Throttling:
Client-side: don't send more than 1 event per 3 seconds
Server-side: rate limit per (user_id, conversation_id) → drop excess
→ At 500M DAU with 10% actively typing: 50M × 1/3s = 16.7M events/sec
→ With subscriber filtering: most events go to 1-5 recipients
→ Actual delivery load: ~50M deliveries/sec (manageable across 200 WS servers)
Group typing:
Show up to 3 names: "Alice, Bob, and 2 others are typing..."
Don't fan out typing to ALL group members — only those with chat open
For 200K-member channel: maybe 500 have it open → 500 deliveries per typing event
Last seen: how to track it (bad → good → great)
"Last seen 5 minutes ago" seems simple, but at 500M DAU it's a surprisingly hard problem. Every user goes online and offline multiple times per day. How do we track this without melting the database?
| Approach | How it works | Write load | Verdict |
|---|---|---|---|
| ❌ Write to DB on every heartbeat | Update a lastSeen timestamp in DynamoDB/Cassandra every time a user responds to a heartbeat (every 30s). | 50M users × 1 write/30s = 1.67M writes/sec just for last-seen | Massive write amplification. Expensive. The data is stale immediately after writing. We're paying for strong consistency we don't need. |
| ⚠️ Write to DB on every activity | Update lastSeen only when user sends a message or performs an action. Reduces frequency but still high. | ~100K writes/sec (only on user actions) | Better, but still writes on every message send. Doesn't capture 'online but idle' state. Users who read but don't send appear offline. |
| ✅ Write only on disconnect | Only write lastSeen to persistent storage when a user disconnects. While online, their status is determined by the active WebSocket connection (in Redis). Combine with querying the live connection state. | ~30K writes/sec (only on status transitions) | Minimal writes. Online status is derived from connection state (free). LastSeen is only written once per session end. 50× fewer writes than heartbeat approach. |
How it works:
1. ONLINE STATUS (real-time, no DB writes):
User connects → Redis key exists → they're online
Determined by: presence:{user_id} key with TTL in Redis
Cost: zero additional writes (already maintained for routing)
2. LAST SEEN (persistent, written only on disconnect):
User disconnects → write to LastSeen table:
UPDATE last_seen SET timestamp = NOW() WHERE user_id = ?
Use conditional write: only update if new timestamp > existing
(prevents race conditions between multiple servers)
3. QUERYING (when Alice opens chat with Bob):
Step 1: Check Redis presence:{bob} → if exists → "Online" (done!)
Step 2: If not in Redis → query LastSeen table → "Last seen 5 min ago"
This means:
→ Online users: answered from Redis (sub-ms, no DB hit)
→ Offline users: answered from LastSeen table (single read)
→ No polling, no periodic writes, no write amplification
4. EDGE CASE: Server crashes without writing disconnect
→ Redis TTL expires (60s) → user appears offline
→ LastSeen table shows the PREVIOUS disconnect time (slightly stale)
→ When user reconnects to a new server, the old server's crash
is detected and LastSeen is backfilled from the TTL expiry time
→ Acceptable staleness: at most 60 seconds off
Last seen privacy
"Last seen" is a privacy-sensitive feature. Users can configure who sees their last-seen timestamp: everyone, contacts only, or nobody. This adds a permission check to every presence query.
Privacy settings (stored in user profile, cached in Redis):
user:{user_id}:privacy:last_seen → "everyone" | "contacts" | "nobody"
On presence query:
1. Fetch target user's privacy setting
2. If "everyone" → return last_seen timestamp
3. If "contacts" → check if requester is in target's contact list
4. If "nobody" → return null (show nothing)
Reciprocity rule (WhatsApp behavior):
If Alice hides her last seen from Bob, Alice also can't see Bob's last seen.
This prevents asymmetric information and reduces privacy complaints.
Implementation:
On presence subscription: check privacy ONCE (on subscribe)
Cache the result for the subscription lifetime
If privacy setting changes: invalidate all active subscriptions for that user
→ Subscribers re-check on next presence event
Presence at scale: the numbers
Redis memory for presence:
500M users × 100 bytes per presence entry = 50 GB
But only online users need entries: 50M × 100 bytes = 5 GB
→ Fits in a single Redis Cluster (6 nodes × 8GB each)
Presence event throughput:
Status changes: ~30K/sec (online/offline transitions)
Typing indicators: ~16.7M/sec (generated) → ~50M/sec (delivered)
Heartbeats: 50M connections / 30s = 1.67M/sec
→ Typing dominates. But typing is fire-and-forget with no persistence.
→ The Presence Service is essentially a real-time event router, not a database.
Subscription tracking:
"Who is subscribed to Bob's presence?"
Redis SET: presence_subscribers:{user_id} → set of subscriber user_ids
Average: 2-3 subscribers per online user (people who have chat open with them)
50M online × 3 subscribers = 150M subscription entries
At 32 bytes per entry: ~5 GB additional Redis memory
🔑 Why presence is separate from messaging
Presence and messaging have opposite reliability requirements. Messages must be delivered exactly once, persisted durably, and ordered correctly. Presence is best-effort, ephemeral, and tolerates staleness. Coupling them means a presence spike (everyone opens the app at midnight on New Year's) could delay message delivery. Separation ensures message delivery SLA is never compromised by presence load.
💡 The WhatsApp optimization
WhatsApp doesn't show real-time "online" status on the contact list — only when you open a specific chat. This is a deliberate engineering decision: showing real-time presence for 500 contacts would require 500 subscriptions per user. By limiting real-time presence to the open chat, they reduce subscriptions from 500 per user to 1-2 per user — a 250× reduction in presence traffic.
Media Pipeline
Media (images, videos, audio, documents) accounts for 10 PB/day of storage — 200× more than text messages. The critical design principle: media never flows through the real-time messaging path. It's uploaded separately to object storage, processed asynchronously, and only a lightweight URL reference travels through the chat pipeline. This keeps the message delivery path fast and the media path independently scalable.
Upload flow: sender side
When a user attaches a photo or video, the client doesn't send the binary through the WebSocket. Instead, it uploads directly to object storage using a , then sends a message containing the media URL. This offloads bandwidth from chat servers to the storage layer.
Client
Select media
Upload Service
Get pre-signed URL
S3 / GCS
Direct upload
Processor
Thumbnail + compress
Chat Service
Send media message
Step 1: Request upload URL
POST /api/v1/media/upload-url
Body: { "file_name": "photo.jpg", "file_size": 4200000, "content_type": "image/jpeg" }
Response: {
"upload_url": "https://s3.amazonaws.com/chat-media/tmp/abc123?X-Amz-Signature=...",
"media_id": "media_abc123",
"expires_in": 900
}
Step 2: Upload directly to S3 (chunked for large files)
PUT https://s3.amazonaws.com/chat-media/tmp/abc123
Content-Type: image/jpeg
Content-Length: 4200000
[binary data]
For files > 10MB: use multipart upload (5MB chunks, resumable)
Client tracks uploaded chunks locally → resume on network failure
Step 3: Confirm upload complete
POST /api/v1/media/upload-complete
Body: { "media_id": "media_abc123" }
Response: {
"status": "processing",
"thumbnail_url": null, // not ready yet
"media_url": "https://cdn.chat.app/media/abc123/original.jpg"
}
Step 4: Send message with media reference
WebSocket frame: {
"type": "message",
"id": "msg_xyz789",
"payload": {
"conversation_id": "conv_abc",
"content": {
"type": "image",
"media_id": "media_abc123",
"media_url": "https://cdn.chat.app/media/abc123/original.jpg",
"thumbnail_url": null, // will be filled by server after processing
"file_size": 4200000,
"dimensions": { "width": 3024, "height": 4032 }
}
}
}
Async media processing
After upload, a background worker processes the media: generating thumbnails, compressing images, transcoding videos, and scanning for malware. This happens asynchronously — the message is delivered immediately with the original URL, and thumbnails/compressed versions become available shortly after.
Trigger: S3 event notification on object creation → SQS → Worker
Processing steps by media type:
IMAGE (JPEG, PNG, WebP, HEIC):
1. Virus scan (ClamAV) → reject if infected
2. EXIF strip (remove GPS, camera info — privacy)
3. Generate thumbnail: 200×200 (for chat bubble preview)
4. Generate medium: 800×800 (for in-app viewing)
5. Keep original (for "download full resolution")
6. Convert HEIC → JPEG (compatibility)
7. Apply lossy compression: quality 85 (saves ~40% storage)
Total processing time: ~2-5 seconds
VIDEO (MP4, MOV, WebM):
1. Virus scan
2. Extract first frame → thumbnail
3. Transcode to H.264 MP4 (universal compatibility)
4. Generate 3 quality levels: 360p, 720p, 1080p
5. Segment for streaming (HLS chunks, 4-second segments)
6. Extract audio waveform (for audio message UI)
Total processing time: 10-60 seconds (depends on duration)
AUDIO (voice messages, MP3, AAC):
1. Transcode to Opus (smaller, better quality at low bitrate)
2. Generate waveform visualization data (array of amplitudes)
3. Calculate duration
Total processing time: ~1-2 seconds
DOCUMENT (PDF, DOCX, etc.):
1. Virus scan
2. Generate preview image (first page as PNG)
3. Extract metadata (page count, title)
Total processing time: ~2-5 seconds
After processing:
Update media record with thumbnail_url, compressed_url, metadata
Push "media_ready" event to sender via WebSocket (update UI with thumbnail)
Recipients who already received the message get thumbnail via a lightweight update frame
Download flow: recipient side
Recipients don't download media automatically. The message arrives with dimensions and file size — the client renders a placeholder. The user taps to download, which fetches from CDN. This saves bandwidth for users on metered connections.
Recipient receives message:
→ Show placeholder with dimensions (no layout shift)
→ Show file size: "4.2 MB"
→ Auto-download thumbnail (tiny, ~5KB) for preview
User taps to view:
→ Fetch from CDN: https://cdn.chat.app/media/abc123/medium.jpg
→ CDN serves from nearest edge PoP (cache hit: ~5ms, miss: ~50ms)
→ Progressive JPEG: shows blurry preview immediately, sharpens as data arrives
Auto-download settings (user configurable):
WiFi: auto-download images + videos < 50MB
Cellular: auto-download images only, < 5MB
Roaming: download nothing automatically
CDN caching:
Media is immutable (never updated, only deleted)
Cache-Control: public, max-age=31536000 (1 year)
Cache key: /media/{media_id}/{variant}.{ext}
Invalidation: only on message deletion (CDN purge by path)
Encryption:
For E2E encrypted conversations:
→ Media is encrypted client-side BEFORE upload (AES-256-GCM)
→ Encryption key is sent inside the message (which is E2E encrypted)
→ Server and CDN only see encrypted blobs — cannot view content
→ Recipient decrypts after download using key from message
Storage tiering and lifecycle
At 10 PB/day, storage cost dominates infrastructure spend. A tiered storage strategy moves media from hot to cold storage based on age and access patterns.
| Tier | Storage class | When | Access latency | Cost |
|---|---|---|---|---|
| Hot | S3 Standard | 0-30 days (recent media) | ~10ms | $0.023/GB/month |
| Warm | S3 Infrequent Access | 30-180 days | ~50ms | $0.0125/GB/month |
| Cold | S3 Glacier Instant | 180 days - 2 years | ~100ms | $0.004/GB/month |
| Archive | S3 Glacier Deep Archive | > 2 years | 12 hours (restore) | $0.00099/GB/month |
S3 Lifecycle policies automatically transition objects between tiers. The CDN cache handles the latency difference — frequently accessed old media stays cached at the edge regardless of backend tier.
Resumable uploads for large files
Videos can be 2GB+. A single HTTP PUT that fails at 1.8GB is unacceptable. Resumable uploads split the file into chunks and track progress server-side.
Chunk size: 5MB (S3 multipart minimum)
Flow:
1. Client: POST /api/v1/media/upload-url
→ Server: initiate S3 multipart upload, return upload_id
2. Client: upload chunk 1 (5MB) → S3 returns ETag
3. Client: upload chunk 2 (5MB) → S3 returns ETag
... (network drops here)
4. Client: POST /api/v1/media/upload-status?media_id=abc123
→ Server: returns list of completed chunks [1, 2]
5. Client: resume from chunk 3
... (all chunks uploaded)
6. Client: POST /api/v1/media/upload-complete
→ Server: calls S3 CompleteMultipartUpload with all ETags
→ S3 assembles chunks into final object
Benefits:
→ Network failure loses at most 1 chunk (5MB), not the entire file
→ Client can pause/resume (background upload while chatting)
→ Progress bar is accurate (chunks_uploaded / total_chunks)
→ Works on flaky mobile networks (3G, subway, etc.)
Timeout:
Incomplete uploads are cleaned up after 24 hours (S3 lifecycle rule)
Client can resume within 24 hours of last chunk
🔑 Why media never goes through WebSocket
The WebSocket connection is optimized for small, frequent messages (~300 bytes each). Pushing a 50MB video through it would: (1) block all other messages on that connection for seconds, (2) consume the server's memory buffering the binary, (3) prevent the server from handling heartbeats (connection appears dead). Separating media upload to a dedicated HTTP path with direct-to-S3 upload keeps the WebSocket lean and responsive.
💡 The cost optimization insight
At WhatsApp scale (10 PB/day), storage cost is ~$7M/month on S3 Standard. With lifecycle tiering: ~$2M/month. With 30-day message expiry (WhatsApp's server-side model): only 300 PB total storage instead of growing indefinitely. The product decision (message retention) is also an infrastructure decision.
End-to-End Encryption
End-to-end encryption (E2E) means the server cannot read message content — only the sender and recipient(s) can decrypt it. This is not just a feature; it fundamentally changes the architecture. The server becomes a blind relay — it routes encrypted blobs without understanding their content. WhatsApp uses the (Double Ratchet) — the gold standard for messaging encryption. Understanding it at a high level is expected in senior interviews.
Key concepts (simplified)
The Signal Protocol uses three layers of keys, each serving a different purpose. You don't need to implement the math in an interview, but you need to explain why each layer exists and what security property it provides.
Layer 1: Identity Keys (long-term)
Each user has a permanent key pair: (identity_public, identity_private)
Generated once on account creation, stored on device
Used to: verify "this message really came from Alice"
Analogy: your passport — proves who you are
Layer 2: Pre-Keys (medium-term, one-time use)
Each device uploads ~100 one-time pre-keys to the server
Used to: establish a session with someone who's offline
When Bob wants to message Alice (who's offline):
→ Bob fetches one of Alice's pre-keys from the server
→ Uses it + his identity key to derive a shared secret
→ Alice can decrypt when she comes online
Analogy: leaving a locked mailbox key at the post office
Layer 3: Message Keys (per-message, ephemeral)
Derived from the "ratchet" — a chain of keys that advances with each message
Each message uses a UNIQUE key that's immediately discarded after use
Compromise of one message key reveals NOTHING about other messages
Analogy: a new lock for every single letter you send
Security properties:
Forward secrecy: compromising today's keys can't decrypt yesterday's messages
Future secrecy: compromising today's keys can't decrypt tomorrow's messages
Deniability: messages can't be cryptographically proven to come from you
(the shared secret could have been created by either party)
Session establishment (X3DH)
Before Alice and Bob can exchange encrypted messages, they need a shared secret. The protocol handles this, even when one party is offline:
Pre-condition: Bob has uploaded to the server:
- Identity public key (IK_B)
- Signed pre-key (SPK_B) — rotated weekly
- 100 one-time pre-keys (OPK_B_1, OPK_B_2, ...) — consumed on use
Alice wants to message Bob for the first time:
1. Alice fetches from server:
- Bob's identity key (IK_B)
- Bob's signed pre-key (SPK_B)
- One of Bob's one-time pre-keys (OPK_B_42) — server deletes it after giving it out
2. Alice performs 3 Diffie-Hellman computations:
DH1 = DH(Alice_IK_private, Bob_SPK) // Alice's identity ↔ Bob's signed pre-key
DH2 = DH(Alice_ephemeral_private, Bob_IK) // Alice's ephemeral ↔ Bob's identity
DH3 = DH(Alice_ephemeral_private, Bob_SPK) // Alice's ephemeral ↔ Bob's signed pre-key
DH4 = DH(Alice_ephemeral_private, Bob_OPK) // Alice's ephemeral ↔ Bob's one-time pre-key
3. Shared secret = KDF(DH1 || DH2 || DH3 || DH4)
→ This is the "root key" that seeds the Double Ratchet
4. Alice sends her first message:
- Encrypted with a key derived from the shared secret
- Includes her identity key + ephemeral key (so Bob can compute the same secret)
- Server relays the encrypted blob to Bob
5. When Bob comes online:
- Receives Alice's message + her public keys
- Performs the same 4 DH computations (with his private keys)
- Derives the same shared secret
- Decrypts the message
- Session is now established — Double Ratchet takes over
Double Ratchet (ongoing messages)
Once a session is established, the Double Ratchet algorithm derives a new encryption key for every single message. It "ratchets" forward — you can't go backward to derive old keys from new ones. This provides forward secrecy at the message level.
Two ratchets working together:
1. Symmetric Ratchet (KDF chain):
Each message advances the chain: key_n+1 = KDF(key_n)
Message key = KDF(chain_key, "message")
After deriving message key → delete chain_key_n (can't go back)
Alice sends msg 1: encrypt with message_key_1, advance chain
Alice sends msg 2: encrypt with message_key_2, advance chain
Alice sends msg 3: encrypt with message_key_3, advance chain
2. Diffie-Hellman Ratchet (asymmetric):
On each reply, the replier generates a NEW ephemeral key pair
New DH exchange → new root key → resets the symmetric chain
Alice sends 3 messages (symmetric ratchet advances 3 times)
Bob replies → new DH exchange → completely new chain
Bob sends 2 messages (new symmetric chain advances 2 times)
Alice replies → another new DH exchange → another new chain
Why both?
Symmetric alone: if one chain key leaks, all future messages in that chain are compromised
DH ratchet: each reply "heals" the chain with fresh randomness
→ Even if an attacker compromises a key, they lose access after the next DH ratchet step
→ This is "future secrecy" (also called "break-in recovery")
Group encryption
Group E2E encryption is significantly more complex than 1:1. The naive approach (encrypt separately for each member) doesn't scale. WhatsApp uses Sender Keys — a shared group key that allows one encryption operation per message regardless of group size.
| Approach | Encrypt operations per message | Security | Complexity |
|---|---|---|---|
| Pairwise (encrypt for each member) | N encryptions for N members | ✅ Best — each member has unique session | ❌ O(N) per message. Unusable for groups > 100. |
| Sender Keys (WhatsApp) | 1 encryption (shared symmetric key) | ⚠️ Good — but no forward secrecy within a sender's chain | ✅ O(1) per message. Key distribution is O(N) but only on join/leave. |
| Server-side encryption only | 0 (server encrypts at rest) | ❌ Server can read messages | ✅ Simplest. Used by Telegram for cloud chats. |
Setup (when Alice joins a group):
1. Alice generates a Sender Key: (chain_key, signing_key)
2. Alice distributes her Sender Key to each group member via their 1:1 E2E session
→ This is O(N) but only happens once per member join
3. Each member stores Alice's Sender Key locally
Sending a message:
1. Alice encrypts message with her current chain key (symmetric, fast)
2. Alice signs the ciphertext with her signing key
3. Server relays the single encrypted blob to all group members
4. Each member decrypts using Alice's Sender Key they stored earlier
→ O(1) encryption, O(1) per-member decryption
Key rotation (on member leave):
When Bob leaves the group:
→ All remaining members generate NEW Sender Keys
→ Distribute new keys to all remaining members (O(N²) messages)
→ Bob's old Sender Key can still decrypt old messages (forward secrecy not perfect)
→ But Bob cannot decrypt any NEW messages (he doesn't have new keys)
Limitation:
Key rotation on member leave is expensive for large groups
WhatsApp limits E2E groups to 1024 members
Telegram doesn't use E2E for large groups (cloud-based instead)
What the server sees (and doesn't see)
Server CAN see (metadata):
✓ Who is messaging whom (sender_id, conversation_id)
✓ When messages are sent (timestamps)
✓ Message size (encrypted payload length)
✓ Media file sizes (encrypted blobs in S3)
✓ Online/offline status
✓ Group membership
✓ Device information
Server CANNOT see (content):
✗ Message text
✗ Media content (photos, videos — encrypted before upload)
✗ Voice message audio
✗ Document contents
✗ Reactions or replies (encrypted as part of message payload)
Implications for system design:
→ Server cannot do content-based spam detection (must use metadata signals)
→ Server cannot generate message previews for push notifications
(client includes a separate encrypted preview for push service)
→ Server cannot search message content (search must be client-side)
→ Server cannot compress/deduplicate message content
→ Abuse reporting: user must forward the decrypted message + sender info to the server
Key management challenges
Challenge 1: Pre-key exhaustion
Problem: Bob uploaded 100 one-time pre-keys. 100 people message him → keys exhausted.
Solution: Server notifies Bob's device to upload more when count < 20.
Fallback: If no one-time keys left, X3DH works without DH4 (slightly weaker).
Challenge 2: Device replacement
Problem: Alice gets a new phone. Her identity key changes.
Solution: Show "safety number changed" warning to contacts.
Risk: MITM attack could replace keys. Safety number verification (QR code) mitigates.
Challenge 3: Multi-device
Problem: Alice has phone + laptop. Both need to decrypt messages.
Solution: Each device has its own identity key + sessions.
Sender encrypts for EACH of Alice's devices separately (pairwise).
→ 1:1 message to Alice with 3 devices = 3 encryptions.
Challenge 4: Message backup
Problem: E2E messages can't be recovered from server if device is lost.
Solution: Client-side encrypted backup to iCloud/Google Drive.
User sets a backup password → derives encryption key → encrypts message DB → uploads.
Server never has the backup key.
Challenge 5: Group key rotation at scale
Problem: 1000-member group, someone leaves → 999 new Sender Keys × 999 distributions.
Solution: Batch key rotation. Don't rotate immediately — batch leaves within a window.
Or: accept slightly weaker forward secrecy for large groups (Telegram's approach).
🔑 The architecture impact of E2E
E2E encryption doesn't just add a crypto layer — it fundamentally changes what the server can do. No server-side search, no content moderation, no message deduplication, no smart replies. Every "intelligent" feature must run on the client device. This is why Telegram offers both: E2E "secret chats" (no server features) and cloud chats (server can search, sync, etc.). It's a product trade-off, not just a security one.
💡 Interview depth calibration
In a 45-minute interview, you won't implement the Signal Protocol. What matters: (1) explain that E2E means the server is a blind relay, (2) mention X3DH for session setup and Double Ratchet for ongoing messages, (3) explain Sender Keys for groups, (4) articulate the trade-offs (no server-side features, key management complexity, multi-device overhead). If the interviewer probes deeper, discuss forward secrecy and key rotation on member leave.
Multi-Device Sync
Multi-device support means a user can be logged in on their phone, laptop, and tablet simultaneously — and all devices stay perfectly in sync. Every message sent from any device appears on all others. Every message received is delivered to all devices. Read status, deletions, and reactions propagate across devices. This is architecturally harder than single-device messaging because it transforms every operation from "deliver to one endpoint" to "deliver to N endpoints and keep them consistent."
The sync model: primary vs companion
There are two architectural approaches to multi-device, and they have fundamentally different trade-offs:
| Model | How it works | Used by | Trade-off |
|---|---|---|---|
| Primary + Companions | Phone is primary. Desktop/tablet are companions that proxy through phone. | WhatsApp (original) | Simple server-side. But: phone must be online for companions to work. Single point of failure. |
| Independent Devices | Each device is a first-class peer. Server delivers to all independently. | Telegram, Signal, WhatsApp (new) | Complex: each device needs its own E2E session. But: works without phone. True multi-device. |
The modern approach (and what you should design in an interview) is independent devices. Each device has its own WebSocket connection, its own encryption keys, and receives messages independently. The server treats each device as a separate delivery target.
Per-device delivery tracking
With multiple devices per user, "delivered" becomes ambiguous. Does it mean delivered to ANY device or ALL devices? The answer: delivered to at least one device is sufficient for the delivery receipt (double checkmark). But the sync protocol must ensure all devices eventually get every message.
Connection registry (Redis):
user:alice:connections = {
"iphone_14": "ws-042:conn_1a",
"macbook_pro": "ws-117:conn_2b",
"ipad_air": null // offline
}
Delivery tracking per device (Cassandra):
CREATE TABLE device_delivery (
user_id UUID,
device_id TEXT,
conversation_id UUID,
last_delivered_seq BIGINT, // last message successfully pushed to this device
PRIMARY KEY ((user_id, device_id), conversation_id)
);
Message delivery flow (Alice receives a message):
1. Chat Service looks up Alice's devices: [iphone, macbook, ipad]
2. iphone: online → push via WebSocket → ACK received → update last_delivered_seq
3. macbook: online → push via WebSocket → ACK received → update last_delivered_seq
4. ipad: offline → skip (will sync on reconnect)
5. Send delivery receipt to sender: "delivered" (at least one device got it)
When iPad comes online:
1. Sync Service checks: device_delivery for (alice, ipad, each conversation)
2. For each conversation: last_delivered_seq < conversation.last_message_seq
3. Fetch and push missed messages: SELECT * FROM messages WHERE conversation_id = ? AND sequence > last_delivered_seq
4. Update last_delivered_seq after successful delivery
Sync protocol on reconnect
When a device reconnects after being offline, it needs to catch up on everything it missed. The sync protocol is the most complex part of multi-device — it must be efficient (don't re-download everything) and correct (don't miss anything).
On device reconnect (WebSocket established + authenticated):
1. Client sends sync request:
{
"type": "sync",
"device_id": "ipad_air",
"conversations": {
"conv_abc": { "last_seq": 4815 },
"conv_def": { "last_seq": 2201 },
"conv_ghi": { "last_seq": 9900 }
}
}
2. Server compares with current state:
conv_abc: server has up to seq 4821 → gap of 6 messages
conv_def: server has up to seq 2201 → no gap (up to date)
conv_ghi: server has up to seq 9950 → gap of 50 messages
3. Server pushes missed messages:
- Small gaps (< 50 messages): push inline via WebSocket
- Large gaps (> 50 messages): send "sync_required" signal,
client fetches via REST pagination
4. Server also pushes:
- Conversation metadata changes (name, avatar, members added/removed)
- Read receipt updates (other members' read positions)
- Message deletions that happened while offline
- Reaction additions/removals
5. Client acknowledges sync complete:
{ "type": "sync_ack", "device_id": "ipad_air", "synced_up_to": { ... } }
Optimization: incremental sync
Don't sync ALL conversations — only those with activity since last online.
Server maintains: device_last_online:{user_id}:{device_id} = timestamp
Query: conversations with last_message_at > device_last_online
Cross-device action propagation
It's not just messages that sync — user actions must propagate across devices too. When Alice reads a conversation on her phone, her laptop should also mark it as read. When she deletes a message, it disappears on all devices.
Actions that propagate:
READ:
Alice reads conv_abc on iPhone (last_read_seq = 4821)
→ Server updates Alice's last_read_seq for conv_abc
→ Server pushes to Alice's other devices: { type: "read_sync", conv: "conv_abc", seq: 4821 }
→ MacBook and iPad clear unread badge for conv_abc
DELETE (for me):
Alice deletes msg_123 on iPhone
→ Server marks: user_message_visibility(alice, msg_123) = hidden
→ Push to other devices: { type: "delete_sync", msg_id: "msg_123", scope: "for_me" }
→ MacBook and iPad hide msg_123
DELETE (for everyone):
Alice deletes msg_123 for everyone (within 1-hour window)
→ Server marks message as deleted in messages table
→ Push to ALL conversation members: { type: "message_deleted", msg_id: "msg_123" }
→ All devices of all members remove the message
TYPING:
Alice types on iPhone
→ Typing indicator sent to conversation members (NOT to Alice's other devices)
→ Her other devices don't need to know she's typing on her phone
MUTE/PIN:
Alice mutes conv_abc on iPhone
→ Server updates user_conversations(alice, conv_abc).muted = true
→ Push to other devices: { type: "settings_sync", conv: "conv_abc", muted: true }
→ All devices reflect the mute
SENT MESSAGE:
Alice sends a message from her MacBook
→ Message delivered to recipients AND to Alice's other devices (iPhone, iPad)
→ Other devices show the message as "sent by me" in the conversation
→ This is how you see your own messages on all devices
Device linking and security
Adding a new device must be secure — an attacker shouldn't be able to link their device to your account. The standard approach: verify the new device using an existing trusted device.
Adding a new device (e.g., linking desktop):
1. Desktop shows QR code containing:
- Desktop's temporary public key
- A session challenge nonce
2. User scans QR code with their phone (trusted device)
- Phone verifies it's a legitimate linking request
- Phone sends to server: "approve device link for user_alice, device_id: macbook_pro"
- Phone shares its message history encryption key with the new device
(encrypted with the desktop's public key from the QR code)
3. Server registers new device:
- Adds to user:alice:devices set
- New device generates its own E2E identity key pair
- Uploads pre-keys for the new device
- Existing contacts get notified: "Alice's security code changed" (new device key)
4. Initial sync:
- New device requests message history from server (last 30 days)
- For E2E messages: history is encrypted, new device needs keys from phone
- WhatsApp approach: phone transfers history directly to new device (local WiFi)
- Telegram approach: server has cloud history (not E2E), just delivers it
Device removal:
- User removes device from settings
- Server: delete device from registry, revoke all sessions
- Contacts' apps: invalidate E2E sessions with that device
- Removed device: can no longer receive messages (WebSocket rejected on auth)
Conflict resolution
With multiple devices, conflicts can arise: Alice deletes a message on her phone while simultaneously reacting to it on her laptop. The system needs deterministic conflict resolution.
| Conflict | Resolution | Why |
|---|---|---|
| Delete on device A, react on device B (same message) | Delete wins (last-write-wins by server timestamp) | Deletion is a stronger intent. User explicitly chose to remove. |
| Read on device A, unread on device B | Read wins (max last_read_seq across devices) | Reading is monotonic — you can't 'unread' something. |
| Mute on device A, unmute on device B | Last-write-wins (server timestamp) | No semantic ordering — use recency. |
| Send from device A and device B simultaneously | Both messages are accepted (different msg_ids) | Not a conflict — both are valid messages from the same user. |
🔑 The multi-device E2E challenge
With E2E encryption and independent devices, the sender must encrypt the message separately for EACH of the recipient's devices. If Bob has 3 devices, Alice encrypts 3 times (one per device, each with its own E2E session). For a group of 50 people with 2 devices each: 100 encryptions per message. This is why WhatsApp limits linked devices to 4 and why Telegram doesn't use E2E for regular chats (cloud model avoids per-device encryption).
💡 The interview framing
"Multi-device sync is essentially a distributed state machine problem. Each device maintains local state, and the server acts as the coordination point. The sync protocol on reconnect is cursor-based — each device tracks its last-seen sequence per conversation and pulls the delta. Cross-device actions (read, delete, mute) propagate via the same WebSocket channel as messages, using dedicated frame types."
Scaling & Reliability
Scaling a messaging system is fundamentally different from scaling stateless HTTP services. The WebSocket connections are stateful, the routing is dynamic, and the failure modes are unique. A crashed server doesn't just drop requests — it disconnects 500K users who must all reconnect and resync. This section covers how to scale from a startup to WhatsApp-level traffic, and how to survive failures at each tier.
Scaling at different tiers
| Scale | Architecture | Why |
|---|---|---|
| 10K DAU | Single WebSocket server, single Postgres, single Redis | One box handles 10K connections trivially. Postgres handles the write load. Ship fast. |
| 1M DAU | 3 WS servers + load balancer, Redis Cluster (3 nodes), Postgres with read replica | 100K concurrent connections need multiple servers. Redis for routing. Replica for read queries. |
| 50M DAU | 50 WS servers, Cassandra (6 nodes), Redis Cluster (12 nodes), Kafka for async | 5M concurrent connections. Postgres can't handle write throughput. Cassandra for messages. Kafka for fan-out. |
| 500M DAU (target) | 200 WS servers (multi-region), Cassandra (50+ nodes), Redis Cluster (30 nodes), dedicated Fan-out Service | 50M concurrent connections across 3+ regions. Geo-routing for latency. Dedicated services per concern. |
| 2B registered | Multi-region active-active, per-region Cassandra clusters, global Redis with local caches, anycast DNS | Users in India shouldn't route through US servers. Each region is self-sufficient for its users. |
Multi-region deployment
At global scale, a single-region deployment means users in India have 200ms+ latency to US servers — unacceptable for real-time messaging. The solution is multi-region deployment with :
Regions: US-East, EU-West, AP-South (India), AP-East (Singapore)
Each region has:
- WebSocket Gateway fleet (handles local connections)
- Chat Service instances (stateless, local routing)
- Redis Cluster (connection registry for LOCAL users)
- Cassandra cluster (messages for LOCAL conversations)
- Push notification service (local APNs/FCM endpoints)
Cross-region routing:
Alice (US) messages Bob (India):
1. Alice's message hits US Chat Service
2. US Chat Service looks up Bob's connection: Redis says "AP-South, ws-server-042"
3. US Chat Service forwards to AP-South Chat Service via inter-region gRPC
4. AP-South delivers to Bob's WebSocket
Latency: ~150ms cross-region (acceptable — within 500ms budget)
Data placement:
Messages are stored in the SENDER's region (write locality)
Recipient fetches from sender's region on sync (read crosses region)
Alternative: replicate to recipient's region async (faster reads, more storage)
Conversation ownership:
Each conversation is "owned" by a region (where it was created)
All sequence assignments for that conversation go through the owning region
→ Guarantees total ordering without cross-region coordination
→ Trade-off: if all members move regions, ordering still goes through original region
Failure-mode playbook
🔥 WebSocket server crashes (500K disconnects)
Detection: health check fails within 10s. Cleanup: bulk-delete connection entries from Redis. Recovery: clients reconnect with jittered backoff (random 0-5s + exponential). Sync protocol delivers missed messages. No messages lost — they're in Cassandra. Impact: 500K users experience ~5-10s reconnection delay.
🔥 Redis Cluster node fails
Redis Cluster auto-promotes a replica within 5-10s. During failover: connection lookups for users on that shard return empty → messages route to offline inbox → delivered on next sync. No message loss. Presence data for affected users shows stale for ~10s. Chat Service has on Redis — falls back to offline delivery path.
🔥 Cassandra node fails
Cassandra replication factor = 3. One node down → reads/writes continue at QUORUM (2/3). No impact on message delivery (delivery is via WebSocket, not Cassandra). Persistence continues on remaining replicas. Node replacement is automated (Kubernetes operator or AWS managed Cassandra). Zero user-visible impact.
🔥 Kafka broker fails
Kafka replication factor = 3. Broker failure → partition leaders re-elect on surviving brokers within seconds. Fan-out Service consumers rebalance. During rebalance (~30s): large group messages queue up, delivery delayed by 30-60s. Small group messages unaffected (they use direct routing, not Kafka).
🔥 Entire region goes down
DNS failover (Route53 health checks) removes region within 60s. Users in that region reconnect to nearest healthy region. Cross-region latency increases (150ms → 300ms) but service continues. Messages stored in the failed region are unavailable for history queries until region recovers. New messages route through healthy regions.
🔥 Push notification service (APNs/FCM) is down
Messages are still delivered to online users via WebSocket. Offline users don't get push notifications — but messages are persisted in Cassandra. When they open the app, sync delivers everything. Push failures are logged; retry with exponential backoff. Users may not know they have messages until they open the app — acceptable degradation.
Graceful degradation priority
When the system is under extreme load, what degrades first? The priority order ensures core messaging survives even when auxiliary features are sacrificed:
Priority 1 (shed first): Typing indicators
→ Ephemeral, best-effort. Drop all typing events under load.
→ Users won't notice immediately. Zero data loss.
Priority 2: Presence updates
→ Stop propagating online/offline changes.
→ Users see stale presence (shows "online" when actually offline).
→ Resume when load decreases.
Priority 3: Read receipts
→ Stop forwarding read receipts to senders.
→ Messages still deliver. Senders don't see blue checkmarks.
→ Batch and send receipts when load recovers.
Priority 4: Large group fan-out
→ Slow down fan-out for groups > 1000 members.
→ Members receive messages with delay (minutes instead of seconds).
→ Small groups and 1:1 unaffected.
Priority 5: Push notifications
→ Rate-limit push notifications (1 per user per 5 minutes).
→ Coalesce: "You have 12 new messages" instead of 12 separate pushes.
Priority 6 (NEVER shed): 1:1 message delivery
→ This is the core product. If this fails, the app is broken.
→ All other features exist to support this.
→ Even under extreme load, 1:1 delivery must work.
New Year's Eve problem (predictable spikes)
Problem: Midnight on New Year's Eve
→ Everyone sends "Happy New Year!" simultaneously
→ 10× normal message volume in a 5-minute window
→ 500M messages in 5 minutes = 1.67M msg/sec (vs normal 1.16M)
Mitigations:
1. Pre-scale WebSocket fleet 2 hours before midnight (per timezone)
→ Add 50% more servers in each region as midnight approaches
→ Auto-scale based on connection count, not CPU
2. Message queuing with backpressure
→ If Chat Service can't keep up, buffer messages in local queue
→ Deliver with 1-2 second delay instead of dropping
→ Users won't notice 2s delay at midnight (everyone's celebrating)
3. Batch delivery receipts
→ Instead of individual receipts, batch: "delivered messages 100-150"
→ Reduces receipt traffic by 50×
4. Disable typing indicators globally
→ Shed lowest-priority traffic first
→ Re-enable after spike passes (5-10 minutes)
5. Push notification coalescing
→ Don't send 50 individual pushes. Send 1: "50 new messages"
→ APNs/FCM have rate limits anyway — respect them
WhatsApp's actual approach:
→ Pre-provision capacity based on previous year's data
→ Stagger "Happy New Year" delivery by a few seconds (users don't notice)
→ Temporarily increase Cassandra write batch sizes
→ They've handled 100B+ messages on NYE successfully
💡 SLO targeting by path
- 1:1 delivery: 99.99% availability, <500ms p99 — the core product
- Group delivery (small): 99.99%, <1s p99
- Group delivery (large): 99.9%, <10s p99
- Presence: 99.9%, best-effort, tolerates 30s staleness
- Push notifications: 99%, <30s delivery
- Media upload: 99.9%, <5s for images, <60s for video
Trade-offs Consolidated
Every decision in this design was a trade. Messaging systems are particularly rich in trade-offs because they sit at the intersection of real-time delivery, durability, privacy, and scale. Bundling them here gives you a compact story to walk the interviewer through.
| Decision | We picked | Why | What we gave up |
|---|---|---|---|
| Transport protocol | WebSocket over TCP | Full-duplex, works through proxies, universal browser support | More memory per connection than UDP. Can't do true peer-to-peer. |
| Message ordering | Per-conversation sequence (Redis INCR) | Total order within conversation. Simple. Fast (100K ops/sec per key). | No global ordering across conversations. Single serialization point per conversation. |
| Delivery semantics | At-least-once + client dedup | Simpler than exactly-once. No distributed transactions needed. | Clients must handle duplicates. Slight complexity on client side. |
| Message store | Cassandra (partition by conversation_id) | Write-optimized LSM. Native TTL. Linear horizontal scaling. Multi-DC. | No ad-hoc queries. Must model for access patterns. No transactions. |
| Connection routing | Redis hash (user → server:connection) | Sub-ms lookups. TTL handles cleanup. Simple data model. | Redis is a SPOF for routing (mitigated by cluster + replicas). |
| Server-to-server delivery | Direct gRPC (primary) + Kafka (fallback) | Lowest latency for online delivery. Kafka handles server-down case. | N² potential gRPC connections between WS servers. Service mesh helps. |
| Group fan-out | Tiered: sync for small, async for large, pull for broadcast | Right strategy per group size. No one-size-fits-all. | More complex code paths. Must maintain size thresholds. |
| Presence propagation | Subscribe-on-open (not broadcast-to-all-contacts) | 250× reduction in presence traffic vs naive broadcast. | Slight delay when opening a chat (need to fetch current status). |
| Encryption | Signal Protocol (E2E for 1:1, Sender Keys for groups) | Gold standard. Forward secrecy. Proven security. | Server can't search/moderate content. Multi-device = N× encryption. |
| Multi-device model | Independent devices (not primary+companion) | Works without phone. True multi-device experience. | Each device needs own E2E sessions. More encryption overhead. |
| Media handling | Direct-to-S3 upload, URL in message | Decouples media from real-time path. CDN delivery. Resumable. | Extra round-trip for upload URL. Slightly more complex client. |
| Message retention | 30-day server-side (WhatsApp model) | Bounded storage growth. Privacy-friendly. Simpler operations. | Users lose history if they don't backup. Can't search old messages server-side. |
| Typing indicators | Fire-and-forget, never persisted | Zero storage cost. No delivery guarantee needed. Simplest possible. | Indicators may be lost on network blip. Acceptable — they're ephemeral. |
| Sync protocol | Cursor-based (last_seen_sequence per conversation per device) | Efficient delta sync. No full re-download. Handles gaps. | Client must track state per conversation. Storage on device. |
Where reasonable engineers disagree
💬 Cassandra vs DynamoDB for messages
Cassandra gives you full control over partitioning, compaction, and multi-DC replication. DynamoDB is fully managed with zero ops burden but costs more at scale and has less flexible data modeling. At WhatsApp scale (~50 TB/day writes), Cassandra's cost advantage is significant. At startup scale, DynamoDB's zero-ops wins. Pick based on team size and operational maturity.
💬 E2E encryption for all messages vs opt-in
WhatsApp: E2E everything (even group chats up to 1024). Telegram: E2E only for "secret chats"; regular chats are cloud-based (server can read). WhatsApp's approach is more secure but sacrifices server-side features (search, sync without device). Telegram's approach enables better UX (instant sync on new device, server-side search) at the cost of trusting the server. Neither is "wrong" — it's a product philosophy.
💬 Message history: server-side vs client-side
Server-side (Telegram): messages live on server forever. New device gets full history instantly. Server can search. But: server has your data. Client-side (WhatsApp): server stores temporarily (30 days). Client is source of truth. Backup to iCloud/Drive (encrypted). More private but worse UX on device switch.
💬 WebSocket vs MQTT vs custom protocol
WebSocket: universal, works in browsers, good tooling. MQTT: lighter weight, designed for IoT/messaging, used by Facebook Messenger. Custom binary protocol: maximum efficiency, used by WhatsApp (XMPP-derived). For an interview, WebSocket is the safe choice. Mention MQTT as an alternative for mobile-optimized scenarios.
💬 Fan-out threshold: 100 vs 1000 vs 10K
At what group size do you switch from sync to async fan-out? Lower threshold = simpler hot path but more Kafka traffic. Higher threshold = Chat Service does more work per message but fewer moving parts. The right answer depends on your Chat Service's capacity and your latency budget. Start at 100, measure, adjust.
🎯 The trade-off that defines seniority
The biggest divide between junior and senior answers is the relationship between encryption and features. A junior says "we'll add E2E encryption" without acknowledging what it costs. A senior says: "E2E means the server is blind — no search, no content moderation, no smart replies, no server-side backup. We accept these limitations for privacy, and we solve search client-side with a local index. That's the trade-off Telegram chose NOT to make for regular chats, and it's why they can offer instant cloud sync."
Follow-ups & Common Traps
The last 10 minutes of the interview separate candidates. The interviewer probes edge cases, failure scenarios, and real-world operational challenges. These are the questions worth pre-loading — each one tests whether you've thought beyond the happy path.
Curveball follow-ups
Q:A user switches from WiFi to cellular mid-message. What happens?
A: The TCP connection (and WebSocket on top of it) dies when the network interface changes. The client detects the disconnect (onclose event), waits for the new network to stabilize (~1-2s), then reconnects with jittered backoff. Any message that was in-flight (sent but not ACKed) is retried with the same message ID — the server deduplicates. The sync protocol on reconnect delivers any messages received during the ~3-5 second gap. From the user's perspective: a brief 'connecting...' indicator, then everything catches up. No messages lost.
Q:How do you handle a group with 200K members where the admin posts 50 messages in a minute?
A: This is the 'celebrity channel' problem. First, the channel uses fan-out-on-read — messages are stored once, not pushed to 200K inboxes. Second, push notifications are coalesced: instead of 50 separate pushes, send 1 every 30 seconds: '15 new messages in Tech News.' Third, only members who have the channel open (foreground) get real-time WebSocket delivery. Everyone else syncs on next app open. Fourth, a dedicated Kafka partition + consumer handles this channel to prevent it from starving other conversations.
Q:User A sends a message, then immediately deletes it. Recipient's device was offline. What happens?
A: The message is persisted to Cassandra with the deletion marker (deleted_at timestamp). When the recipient comes online and syncs, the sync protocol sees the message exists but is deleted — it either skips it entirely (WhatsApp: 'This message was deleted') or delivers the tombstone so the client shows the deletion notice. The key insight: deletion is just another state transition on the message, not a physical removal. The message row stays (with deleted_at set) until TTL expires.
Q:How do you prevent spam in a messaging app without reading message content (E2E)?
A: Metadata-based signals: (1) Rate limiting — new accounts can't message more than 20 unique users/day. (2) Graph analysis — if a user messages 1000 people who never reply, that's spam behavior. (3) Report-based — when users report spam, the reported user's metadata is flagged. (4) Phone number reputation — numbers from known spam ranges are throttled. (5) Behavioral signals — message velocity, account age, contact overlap. WhatsApp bans 2M+ accounts/month using these metadata signals without ever reading content.
Q:Two users send messages to each other at the exact same millisecond. How are they ordered?
A: They get different sequence numbers because they're in the same conversation and the Redis INCR is atomic — one will get sequence N, the other N+1. The order is determined by which message's INCR command reaches Redis first (network race). This is fine — there's no 'correct' order for truly simultaneous messages. Both users see the same order (server-assigned sequence is the source of truth). The messages might appear in different order than the senders intended, but this is physically unavoidable and users don't notice for truly simultaneous sends.
Q:How do you handle message edits after delivery?
A: Edit is a new event: { type: 'edit', original_msg_id: 'msg_123', new_content: '...' }. Server validates: (1) sender owns the message, (2) within edit window (15 minutes). Server updates the message in Cassandra (or appends an edit record). Pushes edit event to all conversation members via the same delivery path as messages. Recipients' clients update the rendered message in-place with an 'edited' label. For offline members: edit is delivered during sync. If original message was already TTL-expired, edit is a no-op.
Q:A WebSocket server is at 500K connections and you need to deploy a security patch. How?
A: Connection draining: (1) Mark server as 'draining' — load balancer stops sending new connections. (2) Send 'please reconnect' frame to clients in batches of 10% every 30 seconds. (3) Clients reconnect to other healthy servers (random assignment). (4) After 5 minutes, ~95% have migrated. Force-close remaining 5%. (5) Deploy patch, restart, rejoin pool. Total: ~6 minutes per server. With 200 servers, rolling deploy takes ~2 hours if done 10 at a time. Users experience a single brief reconnect.
Q:How does 'Delete for Everyone' work technically?
A: Sender sends: { type: 'delete_for_all', msg_id: 'msg_123' }. Server validates: sender owns message, within deletion window (1 hour for WhatsApp). Server: (1) marks message as deleted in Cassandra, (2) pushes deletion event to all conversation members (same fan-out as a message). Online recipients: client removes message, shows 'This message was deleted.' Offline recipients: deletion event delivered during sync. Edge case: if recipient already screenshot/copied the text — nothing we can do. Deletion is best-effort, not a security guarantee.
Q:How do you implement message forwarding detection?
A: When a user forwards a message, the client sends: { type: 'message', payload: { ..., forwarded: true, forward_count: 3 } }. The server increments forward_count. WhatsApp shows 'Forwarded' label and limits forwarding of highly-forwarded messages (>5 forwards) to 1 chat at a time — a misinformation mitigation. The forward_count is metadata attached to the message, not content — so it works with E2E encryption. The server can enforce forwarding limits based on this counter without reading the message.
Q:What happens when a user's phone storage is full and they can't download media?
A: The message (with media URL) is still delivered and shown in the chat with a placeholder. The media isn't auto-downloaded — user sees file size and a download button. If they tap download and storage is full, the client shows an error. The message itself is never lost — it's in Cassandra and the media is in S3. When the user frees space, they can download anytime (until media TTL expires). This is why media URLs are separate from message delivery — delivery succeeds regardless of storage state.
Common traps (and how to avoid them)
Using client timestamps for message ordering
Client clocks are unreliable — timezone bugs, NTP drift, deliberate manipulation. Messages appear out of order.
✅Server-assigned sequence numbers per conversation. The server is the single source of truth for ordering. Client timestamps are metadata only (display 'sent at 3:42 PM'), never used for ordering.
Sending media through the WebSocket connection
A 50MB video blocks the WebSocket for seconds, preventing heartbeats and other messages. Connection appears dead.
✅Media uploads go through a separate HTTP path directly to S3. Only the media URL (tiny) travels through the WebSocket as part of the message payload.
Polling for new messages instead of push
500M users polling every 2 seconds = 250M requests/sec. Wasteful, high latency, battery-draining on mobile.
✅Persistent WebSocket connections with server-push delivery. Polling is only acceptable as a fallback when WebSocket is unavailable (corporate firewalls).
Storing messages in a relational database (Postgres)
At 100B messages/day, Postgres can't handle the write throughput. B-tree indexes degrade. Sharding is painful.
✅Cassandra/ScyllaDB: LSM-tree (write-optimized), native partitioning by conversation_id, built-in TTL, linear horizontal scaling. Use Postgres for user accounts and metadata — not messages.
Broadcasting presence to all contacts
500M users × 500 contacts × 2 transitions/day = 500B presence events/day. System collapses.
✅Subscribe-on-open: only push presence to users who have the specific chat open. 250× reduction in traffic. Poll for contact list presence (batch, every 30s).
Saying 'we'll add E2E encryption' without understanding the implications
E2E isn't a feature you bolt on — it fundamentally changes what the server can do. No search, no moderation, no server-side backup.
✅State the trade-offs explicitly: 'E2E means the server is a blind relay. We lose server-side search and content moderation. We solve search with client-side indexing and spam detection with metadata signals.'
Single fan-out strategy for all group sizes
Sync fan-out for a 200K-member channel blocks the Chat Service for seconds. Async fan-out for a 5-person group adds unnecessary latency.
✅Tiered strategy: sync for small groups (<100), async batched for medium (100-10K), fan-out-on-read for broadcast channels (>10K). Thresholds are tunable.
Making Cassandra persistence synchronous on the delivery path
Cassandra write latency (5-10ms) adds to every message delivery. Under load, write latency spikes to 50ms+.
✅Persist asynchronously. Deliver the message to the recipient FIRST, then persist. If Cassandra is slow, delivery still happens in real-time. Client devices are the ultimate source of truth for E2E messages.
🔥 The deepest trap — designing for WhatsApp when asked for Telegram
WhatsApp and Telegram have fundamentally different architectures because they made different product decisions. WhatsApp: E2E everything, client is source of truth, minimal server storage. Telegram: cloud-first, server stores everything, E2E only for secret chats. If the interviewer says "design Telegram," don't default to E2E everywhere — that's WhatsApp's model. Ask: "Should messages be stored server-side permanently, or is the client the source of truth?" This one question changes the entire architecture.
Quick Revision
A 2-minute scan before the interview. If you can state each of these without looking, you're ready.
Quick Revision Cheat Sheet
Scale anchors: 2B users, 500M DAU, 100B msg/day, 50M concurrent WebSocket connections.
Core constraint: 50M stateful connections — not throughput. Memory and file descriptors, not CPU.
Message ordering: Per-conversation sequence number (Redis INCR). Total order within conversation, not global.
Delivery semantics: At-least-once + client-side dedup. Client-generated UUID for idempotent retries.
Message store: Cassandra. Partition by conversation_id, cluster by sequence DESC. 30-day TTL.
Connection routing: Redis hash: user:{id}:connections → {device: 'server:conn'}. TTL 60s, refreshed by heartbeat.
Online delivery: WS Server → Chat Service → Redis lookup → direct gRPC to recipient's WS server. ~60ms.
Offline delivery: Persist to Cassandra + push notification. Sync protocol delivers on reconnect.
Group fan-out: Tiered: sync (<100 members), async Kafka (100-10K), fan-out-on-read (>10K).
Presence: Redis with TTL. Subscribe-on-open (not broadcast). 3 states: online, recently, offline.
Typing indicators: Fire-and-forget. Never persisted. Auto-expire 5s. Throttled to 1/3s per user.
Media: Direct-to-S3 via pre-signed URL. Async processing (thumbnail, compress). CDN delivery.
E2E encryption: Signal Protocol: X3DH for session setup, Double Ratchet for messages, Sender Keys for groups.
Multi-device: Independent devices. Per-device delivery tracking. Cursor-based sync on reconnect.
Server failure: 500K disconnects → jittered reconnect → sync delivers missed messages. No data loss.
Degradation order: Shed: typing → presence → read receipts → large group fan-out → push. Never shed: 1:1 delivery.
🎯 The 45-minute interview arc
- 0–5 min: Clarify requirements. Ask: group size limit? E2E? Multi-device? Message retention? State SLA targets.
- 5–10 min: Capacity estimation. Derive connections (50M), message QPS (3.5M/sec), storage (50 TB/day). Emphasize connections as the bottleneck.
- 10–15 min: API + data model. WebSocket frame protocol, Cassandra schema (partition by conversation, cluster by sequence).
- 15–25 min: HLD — four paths: online delivery, offline delivery, media, presence. One diagram showing WS servers, Chat Service, Redis routing, Cassandra, Push Service.
- 25–35 min: Deep dive on whichever the interviewer probes — likely message ordering, group fan-out, or WebSocket scaling.
- 35–40 min: Trade-offs. E2E implications, Cassandra vs Postgres, fan-out strategy, presence propagation.
- 40–45 min: Follow-ups. Network switch mid-message, NYE spike, server crash recovery, spam without reading content.
💡 The single sentence that defines a senior answer
"The core challenge isn't message throughput — it's managing 50 million stateful WebSocket connections with dynamic routing, per-conversation ordering without a global clock, and tiered fan-out that adapts from 2-person chats to 200K-member channels. The server is a blind relay for E2E content but maintains full visibility into delivery state and metadata for routing, ordering, and abuse detection."