sehaj/learn
System design/15 Classic designs

15.09 · design · 25 min

Design: chat and messaging

One-to-one and group chat for 20 million daily users: persistent connections and a registry, a sequence number per conversation with retries that never duplicate (tested), syncing devices that were offline, storage partitioned by conversation, and read receipts as one number.

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.

Design it yourself firstmidseniorWebSocketsorderingidempotencysync

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.

25:00

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

ValueWhat it decides
Messages20 million × 40 = 800 million a day ≈ 9,000 a second, about 30,000 at peakWrites are steady and large
Connectionsabout 8 million at peak, at 100,000 per gateway (12.01 Millions of WebSocket connections)About 80 gateway servers plus headroom
Storage800 million × 300 bytes ≈ 240 GB a day, about 90 TB a yearA store that partitions by conversation
DeliveriesMost chats are one-to-one; groups multiply each message by their sizeFan-out per message, bounded at 500

3. Entities and API

text
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 offline

4. High-level design

Snip Bio chat
sendseq + writewhere is Ben?deliverif offlineAna's phoneBen's phoneGateway 1WebSocketsGateway 2WebSocketsChat serviceby conversationMessage storeby conversationConnection registryuser → gatewayPushAPNs, FCM
Store first, then deliver: a message exists once it has a sequence number, whatever happens to the connections.

5 steps. Use Next and Back to follow the request.

Text version
  1. Each phone holds a WebSocket to a gateway. On connect, the gateway records user → gateway in the registry, with a TTL refreshed by heartbeats.
  2. Ana sends a message with an ID her phone generated. The gateway forwards it to the chat service instance that owns the conversation.
  3. The chat service gives it the conversation's next sequence number and stores it. Only then does it acknowledge to Ana: one tick.
  4. It looks up each member's gateways in the registry and sends the message there; Ben's gateway writes it to his socket.
  5. 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

BadSort by the sender's timestamp

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.

GoodSort by server time or a Snowflake ID

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.

GreatA sequence number per conversation

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.

ts
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

BadDeliver only to connected devices

Anything sent while a phone is in a tunnel is lost to it.

GoodQueue undelivered messages per device

Works, but every message becomes a write per recipient device, and a lost queue loses messages.

GreatEvery device remembers the last sequence number it has, per conversation

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

BadOne relational table, one server

The table and its indexes outgrow memory within months.

GoodA relational database sharded by conversation

Works, with the conversation's messages on one shard; large, old conversations make shards uneven.

GreatA wide-column store partitioned by conversation and time bucket

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

2023

Real-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

2017

Message 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 ↗
Predict first

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?

Commit to an answer before you read on.

Recap

Interview questions

SeniorfreqMust-knowDesign

Design a chat application like WhatsApp.

Report a mistakeEdit this page

Used in

  • 17.05Case: Discord's message storage