System Design · Lesson 10 of 15
Chat System (WhatsApp / Messenger)
1:1 and group chat, presence, delivery receipts and message storage.
- Advanced
- 42 min read
- 3 objectives
Before this lessonLesson 9: News Feed (Twitter / Instagram)
What you will learn
- Design the socket and persist-first path
- Store messages by conversation
- Scale groups, presence and media
Your Progress
0 of 15 lessons 0%
- Lessons0 / 15
- Completed0
- Est. time left~ 8 hours
Create a free account to keep your progress on every device.
Design 1:1 and group chat: send text (later media), show online/typing, delivered and read receipts, and never drop a message the UI thought it sent.
Step 1 — Clarify
- 1:1 only, or groups? Group size cap (256 vs 50,000 'channels')?
- One device or many? History on a new phone?
- End-to-end encryption? (If yes, the server stores ciphertext and cannot search bodies.)
- Media? Typing indicators? Unread badges?
v1: 1:1 + groups up to a few hundred, WebSocket/MQTT connection, persist-before-fanout, receipts, presence. Multi-device and E2E are extra you can sketch if asked. Latency: 100–200 ms one-way in-region. Order is per conversation, not global.
Step 2 — Scale (order of magnitude)
dau = 500_000_000
msgs_per_user_day = 40
msgs_per_sec = dau * msgs_per_user_day / 86_400
online_frac = 0.15
connections = dau * online_frac
print("msgs/s", round(msgs_per_sec))
print("concurrent sockets", round(connections))msgs/s 231481 concurrent sockets 75000000
Hundreds of thousands of messages per second and tens of millions of sockets. Connection servers must be a separate fleet from the chat service that writes storage. Each connection box holds ~100k–1M sockets; 75M connections means hundreds of boxes, sticky at the load balancer (or a consistent hash of user id).
Step 3 — Connection model
HTTP polling is too slow and wasteful. Each client keeps a WebSocket (or MQTT) to a connection server. Those servers are stateful. A map in Redis user_id → conn_server (with TTL + heartbeat) lets any chat box find the socket. If the user is offline, the message sits in an inbox until the next connect, when the client syncs from last_received_id.
The chat service writes storage first, then fans out. Online users get a push; offline users get inbox rows and a sync on reconnect.
Step 4 — Storage
conversations(conv_id, type, created_at, last_message_id)
members(conv_id, user_id, last_read_id, role, joined_at)
messages(conv_id, message_id, sender_id, type, body, media_key, created_at)
-- partition key: conv_id
-- clustering: message_id (time-ordered Snowflake)
inbox(user_id, conv_id, message_id) -- offline / multi-device fan-out
-- client_msg_id unique per sender for idempotencyAll messages of a thread live in one partition so a history query is a single range scan. Huge groups: time-bucket the partition (conv_id + yyyymm) so one month cannot grow forever. Media: pre-signed upload to object storage; the message stores the key. Do not use a wide relational join of 'all messages for user' as the primary access path — that is the inbox/sync problem, solved with the inbox table or a per-user queue.
Step 5 — 1:1 send path
- Client sends
{client_msg_id, conv_id, body}on the socket.client_msg_idis the idempotency key. - Chat service dedupes, persists, assigns
message_id, acks the sender (sent). - Lookup recipient connection. Online: push, wait for delivered ack, mark delivered. Offline: write inbox.
- When the UI shows the message, the client emits a read receipt; that is a small event, same fan-out, not a new body.
Never push first and persist later. A crash between push and persist loses the only copy. Client retries with the same client_msg_id so a double persist is a no-op.
Step 6 — Groups and channels
A group of 50: persist once, fan-out notifications to 50 inboxes/sockets. A 'group' of 50,000 is a channel: persist once in the channel partition; members pull from last_read_id on open; you do not write 50,000 copies of the body. Online members can still get a lightweight push (badge + preview) batched through the connection fleet.
Step 7 — Presence, typing, media
Heartbeat every 15–30 s into Redis with a short TTL; last-seen is that key. Typing is pub/sub, not durable — do not write it to Cassandra. Unread counts: increment a Redis counter per (user, conv) on fan-out, reset on open. Media and voice notes follow the Drive/video pattern: bytes in object storage, message is metadata.
// Write your solution here
