Design a Chat System (WhatsApp / Slack)
Real-time messaging at scale. Connection management, per-conversation ordering, guaranteed delivery across flaky mobile networks, and group fan-out.
Covers: Connection tier, message storage and ordering, delivery receipts, offline sync, group chat fan-out, presence
Chat combines almost everything in this course: long-lived connections, ordering guarantees, at-least-once delivery with deduplication, fan-out, and a storage schema that must serve 'the last 50 messages in this conversation' in under 50 ms forever.
- Functional: one-to-one and group chat; delivery and read receipts; offline delivery; message history; presence.
- Scale: 500 M DAU, 40 messages/user/day ≈ 20 B messages/day ≈ 230,000 messages/s average.
- Connections: ~50 M concurrent WebSocket connections at peak.
- Latency: message delivered in under 500 ms when both parties are online.
- Durability: a delivered message must never be lost. This is stricter than most systems in this course.
30-second answer
Assign each message a monotonically increasing sequence number per conversation, allocated server-side - never trust client clocks, because devices are skewed and users change time zones. Persist the message durably before acknowledging to the sender, then deliver at-least-once and let clients deduplicate by a client-generated message ID. On reconnect, the client sends its last-seen sequence number and the server backfills everything newer, so ordering and completeness both come from the sequence rather than from the transport.
The delivery sequence
1 · Client sends with a client-generated ID
A UUID created on the device. This survives retries: if the network drops after the server received it, the client resends with the same ID and the server deduplicates instead of creating a second message.2 · Server persists, then assigns a sequence
Write to durable storage and allocate the next sequence number for that conversation atomically. Persist before acknowledging - an ack for a message that is not stored is a lie the user will eventually notice.3 · Ack to the sender
Single tick: the server has it. This is the only guarantee the sender's device can rely on.4 · Deliver to recipients
Look up each recipient in the connection registry, publish to the gateway holding their socket. If offline, the message simply waits in storage - no separate offline queue is needed because the store is the queue.5 · Receipts flow back
Double tick when the recipient's device acknowledges receipt; blue tick when the conversation is opened. Both are just messages on the same channel, with their own sequence positions.
def send_message(conversation_id, sender_id, client_msg_id, body):
with db.transaction():
# Deduplicate retries using the client-generated id.
try:
db.execute(
"INSERT INTO message_dedup (client_msg_id, conversation_id) VALUES (?, ?)",
client_msg_id, conversation_id,
)
except UniqueViolation:
return db.query_one(
"SELECT * FROM messages WHERE client_msg_id = ?", client_msg_id
) # replay the original result
# Allocate the next per-conversation sequence and persist atomically.
seq = db.query_scalar(
"UPDATE conversations SET last_seq = last_seq + 1 "
"WHERE id = ? RETURNING last_seq", conversation_id,
)
msg = db.execute(
"INSERT INTO messages (conversation_id, seq, sender_id, client_msg_id, body, "
"server_ts) VALUES (?, ?, ?, ?, ?, now()) RETURNING *",
conversation_id, seq, sender_id, client_msg_id, body,
)
fanout.publish(conversation_id, msg) # after commit, never before
return msgOffline sync
A phone is offline more than it is online. The design must treat disconnection as the normal case, not the exception.
// Client stores the highest sequence it has seen, per conversation.
ws.send({ type: "sync", cursors: { "conv_11": 4187, "conv_92": 903 } });
// Server returns everything newer, in order, paginated.
// {
// "conv_11": [ {seq: 4188, ...}, {seq: 4189, ...} ],
// "conv_92": []
// }
//
// Because the store is authoritative and the socket is not, a 3-day offline
// period and a 3-second blip use exactly the same code path.Say these points
- Server-assigned per-conversation sequence numbers, never client clocks.
- Client-generated message IDs make retries idempotent.
- Persist durably before acknowledging the sender.
- Reconnect syncs by last-seen sequence - the same path as offline delivery.
- The store is the source of truth; the socket is only transport.
Avoid these mistakes
- Ordering by client timestamp.
- Acknowledging before the message is durable.
- A separate offline queue duplicating what the store already does.
- No client-side message ID, so a retry creates a duplicate message.
Expect these follow-ups
- The same user is logged in on three devices. How does sync work?
- How do you handle a message that fails to deliver for a week?
- How would you support editing and deleting a message?
30-second answer
Write the message once per conversation and give each member a cursor, rather than copying the message per member - 100,000 copies of every message is untenable in both write volume and storage. Store messages in a wide-column or sharded store partitioned by conversation ID and clustered by descending sequence, so 'the last 50 messages' is a single partition read. For very large groups, stop pushing to every member: deliver in real time only to members with an active connection and currently viewing, and let everyone else pull on open.
| Copy per member | Single copy + cursors | |
|---|---|---|
| Writes per message (100k group) | 100,000 | 1 |
| Storage | 100,000× the message | 1× plus small cursors |
| Read 'my unread count' | Trivial | seq − cursor, also trivial |
| Per-member state (starred, deleted) | Natural | Separate small table |
| Viable at 100k members | No | Yes |
-- Cassandra-style: conversation is the partition key, so the whole
-- conversation lives on one node and recent messages are contiguous on disk.
CREATE TABLE messages (
conversation_id uuid,
seq bigint,
message_id uuid,
sender_id uuid,
body text,
server_ts timestamp,
PRIMARY KEY ((conversation_id), seq)
) WITH CLUSTERING ORDER BY (seq DESC);
-- "last 50 messages" = one partition read, already in the right order.
-- Per-member state stays tiny: one row per member, not per message.
CREATE TABLE member_cursors (
conversation_id uuid,
user_id uuid,
delivered_seq bigint,
read_seq bigint,
PRIMARY KEY ((conversation_id), user_id)
);
-- Unread count = conversations.last_seq - read_seq. No counting, no scanning.Read receipts at scale
In a two-person chat, 'read by' is one row. In a 100,000-member group, showing who has read each message would be 100,000 rows per message and a receipt storm on every open. The standard resolutions:
- Disable per-message receipts above a size threshold - show a read count, or nothing at all. This is what real products do.
- Batch receipts: a client sends one cursor update per few seconds, not one per message.
- Store a cursor, not per-message flags -
read_seq = 4187implies every message up to 4187 is read, in one small write.
Sizing
- Messages: 20 B/day × 300 B ≈ 6 TB/day ≈ 2.2 PB/year before replication. Shard by conversation; archive cold conversations to object storage.
- Connections: 50 M concurrent ÷ 100k per gateway ≈ 500 gateway nodes.
- Fan-out: 230,000 messages/s × average 3 recipients ≈ 700,000 deliveries/s through the pub/sub layer.
Say these points
- Store one copy per conversation plus a cursor per member - never a copy per member.
- Partition by conversation ID, cluster by descending sequence: last-50 is one partition read.
- Cursors make unread counts subtraction and mark-as-read a single write.
- Above a few thousand members, switch from push to pull-on-open.
- Per-message read receipts do not scale - use counts or disable them for big groups.
Avoid these mistakes
- Copying every message into every member's inbox.
- Per-message read-receipt rows in large groups.
- Partitioning by user instead of conversation, making history reads scatter.
- Pushing to 100,000 sockets for one message.
Expect these follow-ups
- How do you support search across a user's entire message history?
- How would end-to-end encryption change this design?
- How do you migrate a conversation to a new shard when it grows too large?
Showing 2 of 2 questions for design-a-chat-system.
Check your understanding
4 questions · no sign-up, nothing stored
Hands-on challenge
Build it - this is what you talk about in a deep-dive round.
Design the storage and delivery layer for a chat product
Support one-to-one chat, groups up to 100,000 members, offline delivery and multi-device sync.
Requirements
- Design the message and cursor schema with partition and clustering keys, and justify both.
- Write the send path showing deduplication, sequence allocation and persistence in one transaction.
- Design the reconnect sync protocol including multi-device cursor reconciliation.
- Define the group-size threshold at which delivery switches from push to pull, with reasoning.
- Size storage, connection nodes and pub/sub fan-out with explicit arithmetic.
Stretch goals
- Add full-text search over a user's history without scanning every conversation.
- Design message deletion (for me / for everyone) consistent across all devices.