Design a chat system

WebSocket sessions, presence, and message ordering - deliver a message exactly once across a fleet of stateful chat servers.

9 min read

Almost all of a chat product is ordinary stateless web work: login, profiles, search, history. Three things are not, and each gets a section here: delivering a message nobody asked for, coping when the recipient is away, and putting a conversation in the right order.

The server has something for you, and cannot tell you

HTTP is a client asking and a server answering. A message arriving for you is an event on the server, and there is no way for the server to start that conversation. The naive fix is for the client to ask, over and over.

A hundred polls: teal found a message, pale red came back empty
Requests a second
500,000
That find a message
5,556/s
Wait for a message
up to 2 s
1,000,000
2 s
A million open apps polling every two seconds: half a million requests a second to collect about 5,600 messages.

A busy user might receive twenty messages an hour. Polling every two seconds is 1,800 requests an hour to collect those twenty, so about 99% of the traffic is the client asking “anything yet?” and being told no. Each of those pays full price (TLS, authentication, a database read), and you still have up to two seconds of delay. Stretch the interval and the request rate drops, along with the product. No setting makes polling both cheap and instant.

Hold the connection open and let the server push

A WebSocket is one connection, opened once, that either side can write to. The chat server holds one per connected client and simply writes new messages down it. That makes the chat server the one genuinely stateful part of the system, and raises a new question: what if the recipient is not connected?

Annchat
server
Ben
Both sockets are open. Send a message.
Ann: ship itdelivered
Ann: hey, you around?delivered
Take Ben offline, send a couple of messages, then bring him back: the store, not the server's memory, is what keeps them.

Holding the message in memory would lose it when that server restarts, and chat systems are not allowed to lose messages. So the delivery path forks. If the socket is open, relay the message now. If it is closed, write it to a message store and hand the wake-up to the notification system. When the phone reconnects, it pulls everything it missed.

Whose clock decides what was said first?

Two people in a group chat, connected to two different chat servers, send moments apart. Everyone must see the messages in the same order, and the two servers do not agree on what time it is.

Ben: PR is greent=770 #1
Ann: hey, you around?t=1000 #0
Ben: lunch?t=1070 #3
Ann: on itt=1300 #2
Ann: see you theret=1600 #4
4 messages render out of the order they were sent. Ben's server clock runs 380 ms slow, so his messages sort above earlier ones.
Ordered by timestamp, Ben's replies jump above the messages they answer. Ordered by sequence, they snap back.

NTP keeps clocks close, not identical, and skew of a few hundred milliseconds is plenty to put an answer above its question. The fix is not better clocks; it is to stop using time as the order. One authority per channel hands out a counter, and messages sort by that.

The counter only needs to be ordered within a conversation, a far weaker and cheaper guarantee than a global order. It doubles as the cursor a reconnecting client uses to ask for “everything after message 4,182”, so ordering and catch-up turn out to be the same mechanism.

A connection the server can write to, a fallback for when it cannot, and an order that does not depend on clocks. Everything else in a chat product is a CRUD app.

The short version

  • Polling wastes nearly every request and still delivers late.
  • WebSockets let the server push; the chat server holds one per client.
  • Offline recipients get the message stored and a push notification, then catch up on reconnect.
  • Order a conversation by a per-channel sequence number, never by server clocks.