Design a Chat App

Design one-to-one and group chat end to end: connection servers, message ordering, offline delivery, receipts, presence and the storage partition key.

Case study34 minLesson 5 of 7
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

Ana sends Ben a message and his phone shows it a moment later, with one tick for sent, two for delivered and a colour change when he reads it. If Ben is in a tunnel, the message waits and arrives when he comes out, in the right place in the conversation.

Interviewers like it because it tests what request-and-response designs never touch: millions of open connections, routing to whichever server holds the recipient, ordering, and what "delivered" really promises. It builds on Realtime Communication, so read that first if WebSockets and connection servers are new. The steps follow A Framework for Any Design.

Clarify the problem

"Chat app" can mean team chat or a phone messenger. Pin it down first.

The first five minutes
Interviewer

Design a chat app like the ones on your phone.

You

One-to-one and groups. How big can a group get? Do you want read receipts and online status?

Interviewer

Groups up to 200 people. Yes to receipts and to "online" or "last seen".

You

Several devices per user, or one phone? And media, or text only?

Interviewer

Assume one device to start. Text only; mention where media would go.

You

Scale: how many daily users and messages?

Interviewer

50 million daily active users, around 40 messages each a day.

You

Then my priorities are: a message the server has acknowledged is never lost, everyone in a conversation sees the same order, and delivery to an online user takes well under a second. Presence can lag by a few seconds. I'll leave out end-to-end encryption, search and media storage unless we have time.

  • Functional: send and receive one-to-one and group messages (up to 200 members); get messages sent while offline; see history; sent, delivered and read receipts; online and last-seen status.
  • Non-functional: an acknowledged message is never lost (durability); one agreed order per conversation; online delivery under 500 ms; 99.99% availability for sending; presence may be a few seconds stale.
  • Out of scope: end-to-end encryption, search, media storage, multiple devices (see the follow-ups).

Estimate the scale

  • Messages: 50 million × 40 = 2 billion a day. 2 × 109 ÷ 86,400 s ≈ 23,000 a second on average. Assume an evening peak of 3x: about 70,000 a second.
  • Deliveries: one-to-one messages go to one person; group messages go to every other member. Assume 80% of messages are one-to-one and the rest reach 20 other group members on average: 0.8 × 1 + 0.2 × 20 = 4.8 deliveries per message. That is 23,000 × 4.8 ≈ 110,000 deliveries a second on average and about 330,000 at peak.
  • Connections: assume 20% of daily users are online at the peak: 10 million open WebSockets. If one connection server holds 100,000 (an assumption: it depends on memory per connection, here about 10 KB, so 1 GB per server), that is 100 servers; plan for 200 so a failure or a deploy has room.
  • Heartbeats: each client pings every 30 seconds so dead connections are noticed. 10 million ÷ 30 ≈ 330,000 pings a second: as many as peak deliveries. They must stay on the connection servers and never touch a database.
Estimate: Message storage per year

How much storage do one year of messages take, before replication?

You know
  • 2 billion messages a day
  • About 200 bytes per stored message (100 bytes of text plus ids and timestamps)
  • A group message is stored once, not once per member

Show the working
  1. 2 × 109 × 200 B = 400 GB a day
  2. 400 GB × 365 ≈ 146 TB a year
  3. With 3 replicas: about 440 TB of disk a year

Estimate 146 TB (anything from 50 TB to 500 TB is a good answer)

Storing group messages once per member instead would multiply this by the 4.8 deliveries per message. That choice comes back in the data model.

Your turn: Define the API

List the messages a client and server exchange over the open connection, and anything that should stay plain HTTP. How does a client retry a send without creating a duplicate?

Compare with a model answer

Sending, delivery, acks, receipts and presence travel over the WebSocket as small typed messages. History and catch-up after a long absence are plain HTTP requests with a cursor. The client puts its own id on every send, so a retry is recognised as the same message.

Define the API

WebSocket messages and HTTP endpoints
Client to server, over the WebSocket
  send     { client_msg_id: "c-7f3a", conv_id: "c_123", body: "lunch?" }
  ack      { conv_id: "c_123", up_to_seq: 1044 }       "I have everything up to 1044"
  read     { conv_id: "c_123", up_to_seq: 1044 }       "I have seen everything up to 1044"
  ping     every 30 s
 
Server to client
  sent     { client_msg_id: "c-7f3a", conv_id: "c_123", seq: 1044, ts: ... }
  message  { conv_id: "c_123", seq: 1044, sender: "ana", body: "lunch?", ts: ... }
  receipt  { conv_id: "c_123", user: "ben", kind: "delivered" | "read", up_to_seq: 1044 }
  presence { user: "ben", online: false, last_seen: ... }
  error    { client_msg_id: "c-7f3a", code: "not_member" | "too_large" | "rate_limited" }
 
HTTP
  GET  /v1/conversations/{conv_id}/messages?before_seq=1000&limit=50   history, newest first
  GET  /v1/sync?since={user_cursor}                                    what changed while offline
  POST /v1/conversations   { members: [...] }                          create a group
       403 not allowed, 404 no such conversation, 429 rate limited

The decision worth arguing is who names a message. The client names it with client_msg_id, an idempotency key, so a retried send is stored once. The server gives it a seq, its position in the conversation, and clients sort by that, never by their own clock. Acks and receipts say "up to seq N", so one small message confirms a whole batch.

Your turn: Model the data

What does a stored message look like, which queries must be fast, and what is the partition key? Think about a group of 200 people.

Compare with a model answer

Messages are stored once per conversation, keyed by conversation id and ordered by seq. The hot queries are "append to conversation" and "messages after seq N in conversation X", which is a wide-column pattern with the conversation as the partition key. Per-member cursors hold delivered and read positions.

Model the data

Tables
messages                       -- about 200 bytes a row
  conv_id        BIGINT        -- partition key (with bucket)
  bucket         INT           -- month number, keeps partitions bounded
  seq            BIGINT        -- clustering key: order inside the conversation
  sender_id      BIGINT
  client_msg_id  VARCHAR(36)
  body           TEXT          -- up to 4 KB
  created_at     TIMESTAMP
  PRIMARY KEY ((conv_id, bucket), seq)
 
dedupe                         -- PRIMARY KEY (conv_id, client_msg_id) -> seq
                               --   kept 1 day; a retried send gets the stored seq back
conversations                  -- conv_id, kind (direct | group), members, last_seq
members                        -- PRIMARY KEY (user_id, conv_id):
                               --   last_delivered_seq, last_read_seq, last_activity

The access patterns are narrow: append to one conversation, 23,000 times a second, and read "the newest 50" or "everything after seq N" in one conversation. Nothing joins. That is the shape a wide-column store (such as Cassandra or HBase) is built for: rows sharing a partition key sit together on disk, sorted by the clustering key, so both reads are one sequential scan of one partition. A relational database works too, but at 146 TB a year you would shard it by hand.

The members table is keyed by user: it answers "which conversations does Ana have, and what has she read?" for the inbox screen and for sync.

Your turn: High-level design

Sketch the boxes: where do sockets live, how does a message on Ana's server reach Ben's server, where is it stored, and what happens when Ben is offline?

Compare with a model answer

Connection servers hold sockets and nothing else. A stateless chat service orders and stores each message, then forwards it to the connection server holding each recipient. An offline recipient gets a phone push and catches up from the store on reconnect.

High-level design

Split the system by what each part remembers. Connection servers hold sockets, so they run no business logic and are deployed rarely. The chat service holds nothing and deploys freely. The session cache holds only who is connected where.

Sockets end at the connection servers. The chat service orders and stores each message, then finds the recipient's server in the session cache; offline recipients get a phone push instead.Phones and browsers and Connection servers (WebSocket); Connection servers and Chat service (send / deliver); Connection servers to Sessions and presence (who is here); Chat service to Sessions and presence (where is Ben?); Chat service to Message store (append); Chat service to Push notifier (Ben offline), asynchronous; Push notifier to Phone push services (notify).Phones andbrowsersConnectionserversSessions andpresenceChat serviceMessage storePush notifierPhone pushservicesWebSocketsend / deliverwho is herewhere is Ben?appendBen offlinenotify

request and replyasynchronous message

Sockets end at the connection servers. The chat service orders and stores each message, then finds the recipient's server in the session cache; offline recipients get a phone push instead.
Ana messages Ben, Ben drops off, Ben catches upUse the arrows or ← → keys
  1. Ana messages Ben, Ben drops off, Ben catches up, step 1Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBenConn server 1Conn server 2Chat servicePhone pushMessage storeSessions
    conversation
    c_123

    1Ana types "lunch?". Her app tags it c-7f3a and sends it to connection server 1.

  2. Ana messages Ben, Ben drops off, Ben catches up, step 2Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBenConn server 1Conn server 2Chat serviceseq 1044Phone pushMessage storeSessions
    conversation
    c_123
    seq
    1044

    2Server 1 passes it to the chat service, which takes the next number for this conversation: 1044. That number is the message's place in the order, for everyone.

  3. Ana messages Ben, Ben drops off, Ben catches up, step 3Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBenConn server 1Conn server 2Chat servicePhone pushMessage storec_123 / 1044Sessions
    conversation
    c_123
    seq
    1044

    3It writes the message to the store and waits for 2 of the 3 replicas. Only now is it safe.

  4. Ana messages Ben, Ben drops off, Ben catches up, step 4Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.Ana1 tickBenConn server 1Conn server 2Chat servicePhone pushMessage storeSessions
    conversation
    c_123
    seq
    1044

    4Ana gets sent with seq 1044: one tick. Her app files the message at position 1044.

  5. Ana messages Ben, Ben drops off, Ben catches up, step 5Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBenConn server 1Conn server 2Chat servicePhone pushMessage storeSessionsben: server 2
    conversation
    c_123
    seq
    1044

    5Where is Ben? The session cache says connection server 2, which wrote that entry when Ben connected.

  6. Ana messages Ben, Ben drops off, Ben catches up, step 6Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBengot 1044Conn server 1Conn server 2Chat servicePhone pushMessage storeSessions
    conversation
    c_123
    seq
    1044
    Ben has up to
    1044

    6The chat service forwards 1044 to server 2, which pushes it down Ben's socket.

  7. Ana messages Ben, Ben drops off, Ben catches up, step 7Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.Ana2 ticksBenConn server 1Conn server 2Chat servicePhone pushMessage storeSessions
    conversation
    c_123
    seq
    1044
    Ben has up to
    1044

    7Ben's app replies ack up_to_seq 1044. The chat service saves his delivered cursor and tells Ana: two ticks.

  8. Ana messages Ben, Ben drops off, Ben catches up, step 8Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBendownConn server 1Conn server 2Chat servicePhone pushMessage storeSessionsben: none
    conversation
    c_123
    seq
    1044
    Ben has up to
    1044

    8Ben enters a tunnel. After 60 seconds without pings, server 2 closes his socket and deletes his session entry.

  9. Ana messages Ben, Ben drops off, Ben catches up, step 9Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBendownConn server 1Conn server 2Chat servicePhone pushMessage storec_123 / 1045Sessions
    conversation
    c_123
    seq
    1045
    Ben has up to
    1044

    9Ana sends "12:30?". It becomes 1045, is stored, and Ana gets one tick.

  10. Ana messages Ben, Ben drops off, Ben catches up, step 10Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBendownConn server 1Conn server 2Chat servicePhone pushnotify benMessage storeSessions
    conversation
    c_123
    seq
    1045
    Ben has up to
    1044

    10The session cache has no entry for Ben, so the chat service queues a phone push notification. The push is a nudge to open the app, not the delivery itself.

  11. Ana messages Ben, Ben drops off, Ben catches up, step 11Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBenConn server 1Conn server 2Chat servicePhone pushMessage storeread after 1044Sessions
    conversation
    c_123
    seq
    1045
    Ben has up to
    1044

    11Ben's phone reconnects and says "I have c_123 up to 1044". The chat service reads everything after 1044 from the store: one message.

  12. Ana messages Ben, Ben drops off, Ben catches up, step 12Ana and Conn server 1; Ben and Conn server 2; Conn server 1 and Chat service; Conn server 2 and Chat service; Conn server 2 to Sessions; Chat service to Sessions; Chat service to Message store; Chat service to Phone push, asynchronous.AnaBenConn server 1Conn server 2Chat servicePhone pushMessage storeSessions
    conversation
    c_123
    seq
    1045
    Ben has up to
    1045

    121045 goes down the new socket, Ben acks it, and Ana sees two ticks. Nothing was lost because nothing depended on Ben being online.

1 / 12

Deep dives

Connection servers and routing between them

With 10 million sockets on 100 to 200 servers, Ana and Ben are almost never on the same one.

Getting a message to the server that holds the recipient
OptionGood atBad atPick it when
Session registry Usual defaultOne lookup per recipient. For a group, the chat service batches recipients by server: one call per server, not per member.The registry must be kept accurate. A server that dies leaves stale entries until they expire.Large scale and groups. My choice here.
Pub/sub channel per userNo registry to maintain: a server subscribes to user:ben when Ben connects.Ten million live channels to shard; a group message is 199 publishes.Smaller systems, or when you already run a pub/sub cluster.
Broadcast to every serverNothing to look up at all.Each of 330,000 deliveries a second goes to 200 servers: 66 million messages a second, almost all thrown away.A handful of servers. Never at this scale.

Keep each registry entry honest with a TTL: the connection server refreshes all its entries every 30 seconds in one batch, and an entry expires after 90 seconds. If a server dies, its entries vanish within 90 seconds; anything routed there meanwhile fails over to the push path, and nothing is lost because the message is already stored.

Deploys are the quiet danger. Restarting a server drops 100,000 sockets at once. Drain servers one at a time, and make clients reconnect after a random delay: spread over 30 seconds, 100,000 reconnects is about 3,300 a second instead of a spike.

Delivery guarantees and ordering

Weak answers say "exactly once" here. Delivery to a phone is at least once: the server resends anything not acknowledged, so a message may arrive twice when an ack is lost. The client drops any (conv_id, seq) it already has. At-least-once delivery plus dedupe on the receiver looks like exactly once on screen; client_msg_id does the same for retried sends.

How to order messages in a conversation
OptionGood atBad atPick it when
Sender's clockFree; no server work.Phone clocks are wrong by seconds or hours. Two people see different orders.Never as the source of order. Fine for showing "sent at".
Server timestampOne clock family, usually close to right.Server clocks drift by milliseconds and two messages can tie.Ordering across conversations, where small errors do not matter.
Per-conversation sequence Usual defaultOne agreed order; gaps are visible, so a client knows when it missed something.Each conversation needs one place that hands out numbers.Any chat. My choice.

The sequence must come from one place per conversation. Either route every send for c_123 to the same chat server (by consistent hashing on the conversation id) and let it keep the counter, or keep the counter in a store that supports an atomic increment or compare-and-set for a single key. Plain quorum reads and writes are not enough: two servers can both read 1043 and both write 1044. If a number is handed out and the write then fails, the gap is harmless; clients treat gaps as "fetch from the store".

Offline delivery and sync

The store is the delivery mechanism; sockets and push notifications only say "there is something new". On reconnect the client calls /v1/sync. The server reads the user's conversation ids from members, looks up each one's last_seq in one batched read, returns those ahead of what the client holds, and the client pulls each missing range. After a week away, the client fetches the conversation list first and each chat's messages when it is opened.

Phone push notifications go through the platform's push service, which is best effort: it can delay or drop them, and it can collapse several into one. Nothing depends on them, so never treat "push sent" as "delivered". The same holds for a socket that died without anyone noticing yet: if Ben does not ack within about 10 seconds, send the push anyway. The notification system case study covers that pipeline.

Receipts and presence

Receipts are cursors, not rows per message. Ben's delivered and read positions are two numbers in his members row. "Read up to 1045" marks every earlier message read with one write. In a one-to-one chat, push each cursor change to the other person. In a group of 200, pushing every member's read cursor to every other member is 200 × 199 ≈ 40,000 messages when everyone reads one message. Instead, show "read by 37" computed when someone opens the message info.

Presence quietly costs the most. Online status lives only in the session cache, with the same 90-second TTL, never in the database. The trap is fan-out. If each user goes online or offline 20 times a day, that is 50 million × 20 ÷ 86,400 ≈ 11,600 changes a second; pushing each one to 100 contacts would be 1.16 million presence messages a second, ten times the message deliveries. So push presence only to people who have that chat on screen; everyone else fetches it when they open the chat.

Storage and the partition key

Partition key for messages
OptionGood atBad atPick it when
Message idPerfectly even spread."The last 50 in this chat" touches 50 partitions.Never for chat history.
Recipient user id (an inbox per user)Sync is one read of one partition.A group message is written once per member, about 4.8 copies on average and 199 in a full group.Very small groups and sync speed above all else.
Conversation id + month bucket Usual defaultHistory and catch-up read one partition; a message is stored once; partitions stay bounded.Sync must first find which conversations changed, from the members table.Groups of any size. My choice.

Without the month bucket, a busy conversation's partition grows forever on the same few nodes. A full group of 200 sending 40 messages each a day writes 200 × 40 × 200 B = 1.6 MB a day; over five years that one partition would hold about 3 GB. Bucketing by month caps it near 48 MB. Reading across a month boundary costs a second query.

Bottlenecks and failures

At 10x (500 million daily users), peak deliveries reach about 3.3 million a second and stored writes about 230,000 a second on average. Connection servers grow to 1,000 or more. The store adds nodes; conversation ids hash evenly. The first thing to strain is the session cache, with 100 million entries refreshed every 30 seconds: shard it by user id. Presence fan-out is next, which is why it is limited to open chats.

When this diesWhat happensWhat you do
A connection serverIts 100,000 users lose their sockets.Clients reconnect to other servers after a random delay and sync from their last seq. Nothing stored is lost.
A chat service instanceSends in flight time out.Clients retry with the same client_msg_id, so no duplicates. Conversations it owned move to another instance.
The session cacheThe chat service cannot find anyone.Messages are still stored and arrive through sync. Do not push every message meanwhile: that is 110,000 pushes a second. Replicate the cache; servers re-register everyone within one refresh.
A message store replicaOne of three copies is gone.Majority writes and reads continue on the other two; the replica is rebuilt.
Phone push servicesOffline users are not nudged.Messages wait in the store and arrive when the app opens.

What a strong answer sounds like

LevelWhat the answer does
JuniorUses WebSockets and a database, and gets messages flowing between two users. Orders by timestamp and says "exactly once" without explaining how.
MidSeparates connection servers, routes through a registry or pub/sub, stores before delivering, and handles offline users with sync.
SeniorDefines delivery as at least once plus dedupe, assigns a per-conversation sequence and says where it comes from, picks the partition key from the group fan-out arithmetic, and notices that heartbeats and presence outweigh the messages.
Defending the orderingStrong answer
Interviewer

Why not order messages by timestamp?

You

Phone clocks are unreliable and server clocks drift by milliseconds, so two people could see different orders. I give each conversation a sequence number from one owner, the chat server that conversation hashes to. Everyone sorts by that number, and a gap tells a client it missed something.

Interviewer

And if that owner crashes mid-conversation?

You

Ownership moves to another instance, which reads the highest stored seq and continues from there. The risk is a short window where two servers both think they own it, so the store write is conditional on that seq not existing yet. The loser retries with the next number.

Follow-up questions

How do you support several devices per user?

The session registry maps a user to a list of (device, server) pairs, and each device keeps its own delivered cursor. A message goes to every connected device; "delivered" for the sender means at least one device acked. Read state syncs across devices as the read cursor moves.

Where do photos and videos go?

The client uploads the file to blob storage with a short-lived signed URL, then sends a normal message holding the file's id. Large files never touch the chat path.

What changes with end-to-end encryption?

The server stores and forwards ciphertext it cannot read, so server-side search and moderation go away, and groups need key distribution among members. Routing, ordering and storage stay the same.

How do edits and deletes work?

An edit or delete is a new message with its own seq that points at the original seq. Clients apply it in order like any other message, so everyone ends with the same history, and offline users catch up through the same sync.

How would you run this across regions?

Users connect to their nearest region. Each conversation has a home region that owns its sequence, so senders elsewhere pay one extra cross-region round trip. Stored messages replicate to other regions for history.

Self-review: chat app0 of 14

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure