SYSTEM DESIGN:Lesson 22: A Hundred Million Messages a Day
Mastering lesson 22: a hundred million messages a day concepts and implementation.
100 million people, one family group
Lesson 21 got a Diwali group working on one server. Now scale it. WhatsApp has 500+ million users in India alone. On Diwali eve, message rate spikes 10×. Every user holds a WebSocket open. Your cousin in Toronto and your nani in Varanasi must see the same message in the same order — mostly. Gateway servers die and reconnect. You need a plan.
This chapter: partition by conversation, WebSocket gateway scaling, presence in Redis, group fan-out, offline sync, napkin math, and interview traps around ordering and encryption.

Millions of WebSocket connections tangled like spaghetti
100 million users. 100 million open connections. One gateway crying.
Chat system architecture
Scaling chat is scaling connections, not just queries.
Napkin math — 100M DAU
Assumptions:
- 100M daily active users
- Average 50 messages sent per user per day (India skews high — festivals, groups)
- 60% of messages in groups (avg 20 members), 40% in 1:1
- Peak hour (8–10 PM IST): 5× average traffic
Total messages per day:
100M × 50 = 5 billion messages/day
5B / 86,400 ≈ 58,000 messages/sec average
Peak (5×): ~290,000 messages/sec
WebSocket connections (concurrent):
100M DAU, ~30% concurrently online at peak = 30M open connections
Each connection ≈ 4–8 KB memory → 120–240 GB RAM across gateway fleet
Storage per day:
5B messages × ~500 bytes (body + metadata) ≈ 2.5 TB/day raw
With indexes + delivery rows: ~5 TB/day
Retention 1 year → plan petabyte-scale (tiered: hot 30 days, cold archive)
State the message volume first. Interviewers care that you know chat is write-heavy, not read-heavy like URL shorteners.
Partition by conversation_id
Shard messages table by conversation_id — all messages in one conversation live on one shard.
Why conversation, not user?
- Message history query is always scoped to one conversation
- No cross-shard joins for "load last 50 messages"
- Group fan-out reads participant list once, writes messages to one shard
def shard_for_conversation(conversation_id: str) -> int:
return consistent_hash(conversation_id) % NUM_SHARDS
Use consistent hashing so adding shards does not reshuffle every conversation (Lesson 9).
User's conversation list spans shards — denormalize user_conversations table keyed by user_id, or maintain inbox index per user.
-- Inbox index (separate from message shards)
CREATE TABLE user_inbox (
user_id BIGINT NOT NULL,
conversation_id UUID NOT NULL,
last_message_at TIMESTAMP,
last_message_preview TEXT,
unread_count INT DEFAULT 0,
PRIMARY KEY (user_id, conversation_id)
);
WebSocket gateway scaling
One server cannot hold 30M connections. You need a gateway tier — stateful servers that maintain WebSocket connections.
Client ──WS──► Gateway (holds connection)
│
├──► Chat Service (business logic)
├──► Message Store (sharded DB)
└──► Redis (connection registry + presence)
Problem: User A connects to Gateway-3. User B connects to Gateway-7. A sends message to B. How does Gateway-7 receive it?
Option 1: Sticky sessions (load balancer affinity)
Load balancer routes user_id → same gateway every time
On send to B: look up B's gateway from Redis, forward via internal RPC
Pros: simple mental model
Cons: rebalance pain when gateway dies; uneven load if power users cluster
Option 2: Connection registry in Redis (recommended)
On connect: SET user:{uid}:gateway = "gateway-7" EX 3600
On message: gw = Redis GET user:{recipient_id}:gateway
internal_publish(gw, { type: "deliver", message: ... })
On disconnect: DEL user:{uid}:gateway
Gateway fleet subscribes to a pub/sub channel per gateway ID. Message arrives on correct node, pushes to WebSocket.
def deliver_to_user(recipient_id: int, payload: dict):
gateway_id = redis.get(f"user:{recipient_id}:gateway")
if gateway_id:
redis.publish(f"gw:{gateway_id}", json.dumps({
"recipient_id": recipient_id,
"payload": payload
}))
else:
# offline — queue for sync + push notification
offline_queue.enqueue(recipient_id, payload)
notification_service.send_push(recipient_id, payload)
Heartbeat every 30 sec refreshes Redis TTL. Missed heartbeat → mark offline, clear registry.
Presence: online / offline / last seen
WhatsApp green dot. "Last seen today at 9:14 PM."
Redis key: presence:{user_id}
Value: { "status": "online", "gateway": "gw-7", "last_seen": 1730474040 }
TTL: 60 sec (refreshed by heartbeat)
Online: key exists, status=online
Offline: key expired or status=offline
Last seen: persist to DB on disconnect; show when offline
Do not query DB for presence on every message route — Redis sub-millisecond lookup.
Privacy: let users hide last seen (product feature, but mention in interview — "we respect presence visibility settings").
Group chat fan-out at scale
256-member office group. One message = 255 deliveries.
At 290k messages/sec peak, ~40% groups, avg 20 members:
fan-out deliveries ≈ 290k × 0.4 × 19 ≈ 2.2M delivery ops/sec
Do not synchronously loop 255 WebSocket pushes in the request path.
def send_group_message(conversation_id, sender_id, body):
msg = message_store.insert(conversation_id, sender_id, body)
recipients = get_participants(conversation_id) - {sender_id}
# Async fan-out via queue
for rid in recipients:
delivery_queue.publish({
"message_id": msg.id,
"recipient_id": rid,
"payload": serialize(msg)
})
ack_sender(sender_id, msg)
return msg
Delivery workers consume queue, route via connection registry. Sender gets "sent" immediately; deliveries complete in milliseconds.
Same pattern as news feed fan-out — push to N recipients async. Festival spike = scale delivery workers horizontally.
Offline message sync
User offline for 6 hours — 400 messages across 12 conversations.
On reconnect:
1. Client: { "type": "sync", "last_ids": { "conv_a": 991, "conv_b": 442, ... } }
2. Server: batch-fetch messages WHERE id > last_id per conversation
3. Return compact payload; client merges
4. Client sends delivery ACKs in batch
Sync API is HTTP (or WebSocket after connect) — not real-time path. Paginate: 100 messages per conversation per sync batch.
Offline queue in Redis:
LPUSH offline:{user_id} serialized_message
On connect: LRANGE + DEL
Short-term buffer for messages sent while user was briefly disconnected (< 24h). Long-term truth stays in sharded message DB.
Push via notification system
User fully offline — app killed, no WebSocket. FCM/APNs push required.
Chat Service → Notification Service → FCM/APNs → device
Notification payload: sender name + message preview (not full E2E ciphertext if encrypted). Tap opens app → sync API fetches full history.
Decouple chat from push delivery. Notification system handles retries, device tokens, rate limits — Notification System. Chat publishes event; notification service consumes.
def notify_offline_user(recipient_id, message):
if not is_online(recipient_id):
notification_service.send({
"user_id": recipient_id,
"channel": "push",
"title": message.sender_name,
"body": truncate(message.body, 100),
"data": { "conversation_id": message.conversation_id }
})
Interview pushback — prepare answers
"How do you guarantee message ordering?"
Per conversation: monotonic message_id (Snowflake or DB sequence per shard). Clients sort by ID.
Across conversations: no global order needed.
Caveat: clock skew between servers can cause rare out-of-order IDs — use server-assigned IDs, not client timestamps. Mention Snowflake IDs.
Group edge case: two members send simultaneously — both get sequential IDs on same shard; order is total within conversation.
"Exactly-once delivery?"
True exactly-once is impossible over unreliable networks. Aim for effectively-once:
1. Client generates client_msg_id (UUID)
2. Server deduplicates: UNIQUE(client_msg_id, sender_id)
3. Retries return same message_id, not duplicate row
At-least-once transport + idempotent server = effectively-once from user's perspective.
"What about end-to-end encryption?"
WhatsApp/Signal encrypt on device; server stores ciphertext blobs.
- Server cannot read message body — no ranking, no abuse scanning on content
- Key exchange via Signal protocol (X3DH + Double Ratchet) — mention, do not implement
- Delivery metadata (who, when) still visible to server
- Push notification shows generic "New message" unless client decrypts locally before backgrounding
Interview line: "E2E is a product/security requirement that changes our threat model. I would flag it early and scope server-side features accordingly."
"Gateway dies mid-conversation?"
- Client detects WS close → exponential backoff reconnect
- New gateway registers in Redis
- Sync API fills gaps since last_ack_message_id
- Brief duplicate delivery possible — client dedupes by message_id
Architecture at scale (verbal)
Clients → LB → Gateway fleet ↔ Redis (registry + presence)
→ Chat Service → Kafka → Sharded Message DB
→ Notification Service (FCM/APNs)
The 5-minute interview answer
- Clarify: 1:1 + group, delivery states, 100M DAU, 50 msg/user/day
- WebSocket gateways + Redis connection registry
- Shard messages by conversation_id; inbox index per user
- Group fan-out async via queue — 255 deliveries per message
- Presence in Redis with heartbeat TTL
- Offline: sync API + notification service for push
- Ordering per conversation via server-assigned IDs; idempotent send with client_msg_id
- Napkin math: ~290k msg/sec peak, 30M concurrent connections