The prompt
"Design WhatsApp" or "design a chat app" looks like a WebSocket problem. The harder parts are elsewhere: putting messages in one order, never showing one twice when a phone retries on a bad connection, and catching up a device that was offline for a week.
Snip Bio adds messaging between creators and their followers: one-to-one chats and groups of up to 500 people. There are 20 million daily users who each send about 40 messages a day, on several devices each. Messages to online users should arrive in under half a second; offline users get them when they return, plus a push notification. History is kept and available on every device.
1. Requirements
Functional: send and receive messages in one-to-one and group chats; history on every device; sent, delivered and read indicators; online status and "last seen".
Non-functional: under 500 ms delivery to online recipients; every message in the same order for every member; no duplicates and no lost messages; history kept indefinitely.
Out of scope: voice and video calls, media processing (13.01 Direct-to-storage uploads), end-to-end encryption (with it, the server stores ciphertext it can't read; the design below barely changes).
2. Estimates
| Value | What it decides | |
|---|---|---|
| Messages | 20 million × 40 = 800 million a day ≈ 9,000 a second, about 30,000 at peak | Writes are steady and large |
| Connections | about 8 million at peak, at 100,000 per gateway (12.01 Millions of WebSocket connections) | About 80 gateway servers plus headroom |
| Storage | 800 million × 300 bytes ≈ 240 GB a day, about 90 TB a year | A store that partitions by conversation |
| Deliveries | Most chats are one-to-one; groups multiply each message by their size | Fan-out per message, bounded at 500 |
3. Entities and API
Conversation { id, members[], last_seq }
Message { conversation_id, seq, client_msg_id, sender_id, body, sent_at } primary key (conversation_id, seq)
ReadState { user_id, conversation_id, read_up_to_seq }
WebSocket ← { type: "send", conversation_id, client_msg_id, body }
→ { type: "ack", client_msg_id, seq }
→ { type: "message", conversation_id, seq, sender_id, body }
GET /conversations/{id}/messages?after_seq=N&limit=100 catch up after being offline4. High-level design
5 steps. Use Next and Back to follow the request.
Text version
- Each phone holds a WebSocket to a gateway. On connect, the gateway records user → gateway in the registry, with a TTL refreshed by heartbeats.
- Ana sends a message with an ID her phone generated. The gateway forwards it to the chat service instance that owns the conversation.
- The chat service gives it the conversation's next sequence number and stores it. Only then does it acknowledge to Ana: one tick.
- It looks up each member's gateways in the registry and sends the message there; Ben's gateway writes it to his socket.
- Members with no live connection get a push notification instead. When their app opens, it asks for everything after the last sequence number it has.
- Ana's phone ↔ Gateway 1
- Ben's phone ↔ Gateway 2
- Gateway 1 → Chat service: send
- Chat service → Message store: seq + write
- Chat service → Connection registry: where is Ben?
- Chat service → Gateway 2: deliver
- Chat service → Push: if offline
5. Deep dives
Deep dive
Putting messages in one order
Phones' clocks are wrong by seconds or hours (05.06 Clocks, skew and ordering IDs). Two members would see different orders, and a reply could appear above its question.
Better, but two servers' clocks differ by milliseconds, so near-simultaneous messages in a group can still swap, and there's no way to spot a missing message.
One owner per conversation assigns 1, 2, 3… as it stores each message. Every member sees the same order, and a gap tells a device it missed something. A client-generated message ID makes retries safe.
export async function sendMessage(db: pg.Pool, conversationId: number, clientMsgId: string, senderId: number, body: string) {
const client = await db.connect();
try {
const dup = await client.query("SELECT seq FROM messages WHERE conversation_id = $1 AND client_msg_id = $2", [
conversationId,
clientMsgId,
]);
if (dup.rowCount) return Number(dup.rows[0].seq); // a retry: same message, same number
await client.query("BEGIN");
const { rows } = await client.query(
"UPDATE conversations SET last_seq = last_seq + 1 WHERE id = $1 RETURNING last_seq",
[conversationId],
);
const seq = Number(rows[0].last_seq);
await client.query(
"INSERT INTO messages (conversation_id, seq, client_msg_id, sender_id, body) VALUES ($1, $2, $3, $4, $5)",
[conversationId, seq, clientMsgId, senderId, body],
);
await client.query("COMMIT");
return seq;
} catch (err: any) {
await client.query("ROLLBACK"); // also undoes the sequence increment, so no gap
if (err.code === "23505") return sendMessage(db, conversationId, clientMsgId, senderId, body); // raced a retry
throw err;
} finally {
client.release();
}
}The messages table has PRIMARY KEY (conversation_id, seq) and UNIQUE (conversation_id, client_msg_id). Tested on
Postgres 16: 50 messages sent to one conversation at once got sequence numbers 1 to 50 with no gaps. Retrying message
7 twice at the same moment returned its original number both times. A new message sent three times at once (a phone
retrying on a flaky network) was stored once, as number 51, and the counter stayed at 51: the losing attempts rolled
back their increments. 500 messages into one conversation took 120 ms; the row lock on the conversation serialises
its writes, which is fine for a group of 500 and a reason to avoid one counter for a channel of millions.
Deep dive
Catching up a device that was offline
Anything sent while a phone is in a tunnel is lost to it.
Works, but every message becomes a write per recipient device, and a lost queue loses messages.
On reconnect, it asks for messages after that number, a keyset query on the partition (05.07 Keyset pagination). The message store is the only source of truth; live delivery is just a fast path, and a gap in sequence numbers triggers the same catch-up.
Deep dive
Storing 90 TB a year
The table and its indexes outgrow memory within months.
Works, with the conversation's messages on one shard; large, old conversations make shards uneven.
The dominant query is "the latest messages in conversation X, then earlier ones", which reads one partition in order. Bucketing by time keeps a decade-old group's partitions bounded (04.11 Choosing a database), and writes are append-heavy, which suits an LSM engine (09.03 The LSM write and read path).
Receipts and presence. "Read" isn't a flag per message per reader: it's one number per member per conversation,
read_up_to_seq, updated as they scroll. "Delivered" works the same way. Presence uses heartbeats with a timeout and
is shown only to people who have the chat open (12.02 Presence).
Very large groups. Above a few thousand members, fan-out per message becomes the cost. Systems like Slack route each channel to a stateful channel server that pushes to the gateways subscribed to it.
6. Wrap-up
What breaks first: reconnect storms when a gateway restarts and 100,000 clients reconnect at once, so clients back off with jitter and gateways drain slowly (12.01 Millions of WebSocket connections); then hot conversations, a single group whose owner instance and partition get every write.
What's expected at each level
Junior
gets a working design
- Uses WebSockets and a database of messages
- Delivers to online users and stores for offline ones
Mid-level
explains the trade-offs
- Finds the recipient's gateway through a registry
- Orders by a per-conversation sequence and deduplicates retries
- Sends push notifications to offline users
Senior
drives it and goes deep
- Syncs devices from their last sequence number, with the store as the source of truth
- Partitions storage by conversation and time bucket
- Models receipts as a number per member and plans for reconnect storms and huge groups
Seen in production
Slack
2023Real-time messaging
Every Slack client keeps a persistent WebSocket to Slack's servers. Clients connect to gateway servers in the nearest region, which track each user's channel subscriptions. Channel servers are stateful and in memory; each owns a subset of channels, assigned by consistent hashing. When a message arrives, its channel server sends it to every gateway subscribed to that channel, and Slack says messages are delivered across the world in about 500 ms.
What we foundPersistent WebSockets terminate on gateway servers; stateful channel servers, found by consistent hashing, fan messages out to them.
Real-time Messagingslack.engineering ↗Discord
2017Message storage
Discord started on MongoDB. By late 2015 it had 100 million stored messages, and once data and indexes no longer fit in RAM, latencies became unpredictable. It chose Cassandra after listing its requirements, and partitioned messages by channel and a time bucket, since every query operates on a channel.
What we foundMessages partitioned by channel and a time bucket, because every query reads one channel.
How Discord Stores Billions of Messagesdiscord.com ↗Ben's phone has messages 1 to 40 of a group, then loses signal. Messages 41 to 47 arrive while he's offline, and when he reconnects the live connection delivers 48. What should his app do?
Recap
Interview questions
Design a chat application like WhatsApp.