LearnHLDDesign a chat system

Design a chat system

Every other system in this course is request and response. The client asks, the server answers, and between requests the server has forgotten the client exists.

Chat is not that. The server has to push. That means holding a connection open for every user who has the app in front of them, and knowing which of a hundred servers holds the connection for the person you are messaging. One requirement, holding state per user, is what makes this question different from everything else.

Step 1: Understand the problem

Chat questions go wrong in the first five minutes, when a candidate starts drawing WebSockets before anyone has agreed how big a group is or whether messages can be lost.

You askThey sayWhat it settles
One to one, groups, or both? How big can a group be?Both, and groups cap at about 500.The delivery cost of one message. At 500 members a send becomes 500 deliveries, which is fine. At 500,000 it is a broadcast problem and a completely different design.
How many people are connected at once?About 50 million at peak.The resource that runs out. Connections, not requests. That single number is why the design has a tier whose only job is holding sockets.
Can a message ever be lost?Never. Duplicated is annoying, lost is unacceptable.Persist before delivering, and give the client a way to detect gaps. It also tells you which side of the at-least-once trade to land on.
Does order have to hold across conversations?Only within a conversation.A per conversation sequence number, assigned by the server. Global ordering would need consensus and nobody would notice the difference.
What happens to a message for someone who is offline?They get it when they come back, on any device.The store is the source of truth and delivery is a push on top of it. Clients sync by sequence number rather than replaying a queue.
Do we need end to end encryption?Not today.A large cut, and worth naming: with E2E the server cannot search, moderate or generate previews, and group key management becomes its own design question.

What you are building

In scope
  • One to one messaging. Delivered in near real time to a device that is online.
  • Small group chats, up to about 500 members. The size cap is a design decision, not an accident.
  • Message history, and delivery when the recipient was offline. A message must never be lost because nobody was listening.
  • Sent, delivered and read receipts. Cheap to describe, surprisingly expensive in groups.
The numbers you commit to
  • 50 million concurrent connections at peak.
  • Message delivered in under 500ms when both parties are online.
  • Messages are never lost, never duplicated, and never shown out of order within a conversation.
  • A server restart must not lose messages or drop users permanently.
Cut, and say so out loud
  • Voice and video calls. Different transport entirely, and the signalling is its own design question.
  • End to end encryption. Worth one sentence: it makes server side search and moderation impossible, which is a product decision more than a technical one.
  • Broadcast channels with millions of members. That is the news feed fan out problem wearing a different hat.

Back of the envelope

Connections, not requests, are the resource that runs out.

Connection budget
50 million
50 thousand
40
Connection servers needed50M / 50k = 1,000
Memory per server for connections50k x ~10KB = 0.5 GB
Messages per second50M x 40 / 86,400 = 23,148
Storage per year at 200 bytes146 TB
1,000 connection servers
and every one of them is stateful, which is the whole problem

23,148 messages a second is not a hard number on its own. The hard number is 1,000 stateful servers, because now every message needs to find the right one, and every deploy disconnects tens of thousands of users at once.

Step 2: Propose the high level design

Choosing the transport

Transport
The client makes a request the server holds until there is something to say. Works through every proxy and firewall on earth, which is why it survives. Every message costs a full HTTP round trip to re-establish, and there is a gap between responses where the client is not listening.

The API

GET/v1/ws
returns 101 Switching Protocols
Why: Authenticate before the upgrade, with a short lived token rather than a session cookie. Once the socket is open there is no second chance to reject it, and a long lived connection authenticated once needs a way to be revoked.
POSTover the socket: send
{
  "type": "send",
  "conversationId": 9912,
  "clientMsgId": "c_7f3a91b2",
  "body": "on my way"
}
returns ack { "seq": 40871, "serverTime": "..." }
Why: The acknowledgement carries the sequence number, which is what the client needs to order its view and to detect gaps later. Without it the client is guessing at ordering from arrival time.
GET/v1/conversations/{id}/messages?after=40800&limit=50
returns 200 { messages: [...], hasMore: true }
Why: After a sequence number, not a timestamp or an offset. This is how a client that was offline catches up, and it is exact rather than approximate.

The data model

messageswide column store, partitioned by conversation_id
conversation_idbigintPKPartition key. All of one conversation lives together, which is exactly how it is read.
seqbigintPKClustering key, ascending. Ordering within a conversation, assigned server side.
client_msg_idchar(32)UQGenerated by the sender. The unique constraint is the whole duplicate defence.
sender_idbigint
bodyvarchar(4096)
created_attimestampServer time. Client clocks are wrong often enough to matter.
Sample row
9912 | 40871 | c_7f3a91b2... | 8821 | "on my way" | 2026-08-25 14:32:07
Partitioning by conversation is the single most important choice here. Reading a chat is then one partition scan in sequence order, with no merging and no cross-shard work, which is the query that runs constantly.

The whole system on one whiteboard

Figure 1. The only stateful box in the picture is the connection server, and all it holds is sockets. Everything that decides anything is stateless and can be restarted mid conversation.

Walking Figure 1:

  1. The sender already has a socket open to connection server 47. Which server that is was decided once, at connect time, by the discovery service.
  2. The message arrives over that socket. The connection server does no thinking: it hands the message to the message service and goes back to reading sockets.
  3. The message service asks the session registry where the recipient is connected, if they are connected at all. For a group it asks the group service for the member list first and then does this once per member.
  4. It writes the message to the store before delivering it. This ordering is the whole durability story and it is worth saying out loud.
  5. Delivery is a direct call to the connection server holding the recipient, which pushes it down the socket.
  6. If nobody is connected, the message is already safe in the store, and a push notification nudges the device instead. The client will pull what it missed by sequence number when it opens.

Two things about Figure 1 are worth defending out loud. The connection servers hold no business logic, so they can be restarted and scaled independently of everything else. And the message is persisted before it is delivered, not after, because a message that was delivered but not stored disappears when the recipient reinstalls the app.

Your answer

Step 3 in Figure 1 is a lookup that has to answer in about a millisecond, for 50 million connected users, and be correct the instant someone's phone loses signal. How would you build the session registry?

Step 3: Design deep dive

Finding the recipient

This is the question the whole design turns on, and it is the one the interviewer will ask first.

1/6 Server 47 holds the sender’s socket. It has no idea where user 8821 is, and it should not: keeping a map of every user on every server would not fit and could never stay current.
The registry is a cache, not a source of truth

It will be wrong. A server dies and its entries linger until the TTL expires, so deliveries route to a machine that is gone. That has to be survivable: delivery failure means fall back to stored-and-offline, and the client picks the message up when it reconnects. A design that requires the registry to be correct is a design that breaks every time a server restarts.

Ordering and duplicates

“Messages must not arrive out of order” sounds like it needs a global clock. It does not, because ordering only has to hold within one conversation, and one conversation is small.

Give every conversation a sequence number, assigned by the message service when it persists. Clients render by sequence number, not by arrival time or by a client timestamp, which is untrustworthy because phone clocks are wrong and users travel through time zones.

Duplicates come from retries. A client sends, the network drops the acknowledgement, the client retries, and the message is now stored twice. Fix it the same way as everywhere else in this course: the client generates an id for the message, and the store has a unique constraint on it. A retry then collides and returns the original.

Gaps come from a client being offline briefly. Since the client knows the last sequence number it holds, it asks for everything after that on reconnect. A missing message is something the client can detect and repair on its own, which is much better than hoping the server noticed.

Groups, and the number that surprises people

A group of 500 members means one sent message becomes 500 deliveries and, if you are not careful, 500 read receipts each of which is itself a message to 500 people. That is 250,000 messages from one person typing “ok”, and it is how group chat features get quietly capped.

Three things keep it bounded. Cap group size, because the cost is quadratic and no amount of engineering changes that. Aggregate receipts rather than forwarding each one, so clients get a count and fetch details only when someone taps. And fan out to connection servers rather than to users: if 40 of the 500 members are on server 12, that is one message to server 12 with a list of recipients, not 40 messages.

That last one is the general trick. Deduplicate the fan out by destination, not by recipient.

Break it

Connection layer
steady
steadyone server diesreconnect stormjittered
Healthy. 50 million sockets across a thousand servers. Connections are long lived, so the connection rate is low and the registry sees only heartbeats.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
WebSocket over long pollingFull duplex, low per message overhead, and no gap where the client is not listening.Stateful servers, so deploys disconnect users and load balancing has to be connection aware.Real time chat. Long polling stays as a fallback for networks that block upgrades.
Session registry over broadcasting to all serversOne small key read finds the recipient, and message traffic scales with messages rather than with server count.Shared state in the delivery path, and it is stale for a few seconds after any server dies.Any connection fleet past a handful of servers. Broadcasting is fine at ten and hopeless at a thousand.
Persist before deliverA message can never be seen by a recipient and then vanish, which is the failure users never forgive.A storage write in the delivery path, so latency depends on the store.Always, for anything a person will look for again later.
Per conversation sequence numbersExact ordering and gap detection with no global clock and no coordination between conversations.No total order across conversations, so a unified view of everything is only approximately ordered.Always. Ordering across conversations is not something users can perceive anyway.

Interview replay

Interviewer
User A on server 47 sends to user B, who is connected to server 12. How does the message get there?
The core question, asked first. Everything else in the round hangs off this answer.
You
A session registry, a small Redis keyed by user id holding which connection server owns their socket, written on connect and refreshed by heartbeat. Server 47 hands the message to the message service, which looks up B, persists the message, then pushes it to server 12. If the registry has no entry, B is offline: the message is already stored and a push notification goes out instead.
Names the component, what is in it, who writes it, and the offline path. Four sentences covering the whole mechanism.
Interviewer
What if the registry is wrong?
Checking whether you treat it as a cache or as truth.
You
It will be, every time a server dies, until the TTLs expire. So delivery failure has to be a normal path, not an error: if the target server does not accept it, treat the recipient as offline. The message is persisted either way, and the client asks for everything after its last sequence number when it reconnects. The system has to work when the registry is stale, because it regularly is.
Designing for the stale case rather than trying to prevent it is the answer that shows operational experience.
Interviewer
How do you guarantee ordering?
People reach for global ordering here and make it much harder than it is.
You
Only within a conversation, which is the only place it matters. The message service assigns an ascending sequence number per conversation when it persists, and clients render by that rather than by arrival time or client timestamp, since phone clocks are unreliable. It also gives the client gap detection for free: it knows its last sequence number, so it can ask for anything it missed.
Narrows the requirement before solving it. That reframing is worth more than the mechanism.
Interviewer
You deploy the connection tier. What happens?
Stateful servers make deploys a design question, and most candidates never think about it.
You
Every connection on a restarting server drops, so a rolling deploy across a thousand servers is a rolling reconnect storm. I would drain rather than kill: stop accepting new connections, ask clients to reconnect gradually over a window, then shut down. And clients need randomised backoff, because without it everything that dropped together comes back together and the wave is worse than the deploy.
Drain plus jitter is the concrete answer, and mentioning that jitter lives on the client is the detail that makes it real.

Checkpoint

Checkpoint

1. Why persist the message before delivering it rather than after?

2. What is the actual cause of a reconnect storm after a network blip?

3. A 500 member group generates far more traffic than expected. What is the biggest contributor?

Worth memorising
  • 50 million concurrent connections. Connections, not requests, are the resource that runs out.
  • About 100,000 sockets per connection server, so a few hundred servers exist only to hold them open.
  • A 500 member group turns one send into 500 deliveries. The size cap is a design decision.
  • Persist before delivering. A message delivered but not stored disappears when the app is reinstalled.
Say this in 60 seconds

The thing that makes chat different is that servers hold state: a WebSocket per online user, so a message has to find the one server holding the recipient's socket. I would keep a session registry, a small Redis mapping user id to connection server, written on connect and refreshed by heartbeat. Send goes to the message service, which looks up the recipient, persists the message, then pushes it to that server, and if there is no entry the recipient is offline so it is already stored and a push notification goes instead. The registry is a cache and it will be stale after a crash, so failed delivery has to fall back to the offline path rather than error. Ordering is per conversation with a server assigned sequence number, which also lets a reconnecting client detect and fetch gaps, and duplicates are handled by a client generated message id with a unique constraint. The failure I would design for is the reconnect storm: drain connections on deploy and randomised backoff on the client.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.