Learn / DS & Algo / System Design / Chat System (WhatsApp / Messenger)

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))
Output
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.

flowchart LR M[Mobile] -->|WebSocket| CS[Connection servers] CS --> CH[Chat service] CH --> Store[(Message store)] CH --> MQ[(Queue)] MQ --> CS2[Recipient connection server] CS2 --> M2[Recipient] CS --> Pres[(Presence Redis)]

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 idempotency

All 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

  1. Client sends {client_msg_id, conv_id, body} on the socket. client_msg_id is the idempotency key.
  2. Chat service dedupes, persists, assigns message_id, acks the sender (sent).
  3. Lookup recipient connection. Online: push, wait for delivered ack, mark delivered. Offline: write inbox.
  4. 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
Up next · Lesson 11Video Streaming (YouTube)Upload, transcode, CDN playback, recommendations and comments.