Lesson 63 — System Design VII: a chat system

~20 minutes · Day 81 · system design · chat

Tonight's interview design is a WhatsApp-style chat system. The product feels simple: one person sends a message, another person sees it quickly, and the app shows online status and ticks. The interview is not about copying WhatsApp internals. It is about keeping three things separate: the open connection, the durable message, and the tiny side signals around it.

The invariant

Chat is fan-out over persistent connections: WebSockets hold the pipe open, queues absorb bursts, and presence and receipts live as separate small services.

That sentence is the design. A chat app is not a normal request-response page where the client asks once and leaves. Online clients hold long-lived pipes, messages are persisted before delivery is trusted, and small noisy events like “online” and “read” should not overload the message store.

The script

The system-design script is always: requirements → estimate scale → API → data model → high-level boxes → deep-dive the bottleneck → failure modes. For chat, apply it in that order:

  1. Requirements. Send one-to-one and small-group text messages. Deliver immediately if the recipient is online. Store messages for offline users. Show presence and delivered/read receipts as best-effort signals.
  2. Estimate scale. Separate concurrent connections from message rate. Ten million online devices means ten million open sockets, even if only a fraction are sending each second.
  3. API. Clients connect with WS /chat, then send events such as send_message, ack_delivered, and ack_read. A WebSocket gives a long-lived two-way session (MDN WebSocket API).
  4. Data model. Store users, conversations, members, messages, and delivery_state. Keep presence separate with short TTLs.
  5. High-level boxes. Mobile apps connect to WebSocket gateways. The chat service persists the message, enqueues delivery work, and workers push to the right gateway.
  6. Deep-dive the bottleneck. The interesting choice is fan-out: when one message has several recipients, do you create delivery work on write, on read, or a hybrid?
  7. Failure modes. Gateway crash, reconnect storm, queue lag, duplicate delivery, offline recipients, stale presence, and receipts arriving out of order.
client -> server: send_message { conversation_id, client_msg_id, body }
server -> client: message_saved { message_id, server_time }
server -> peer:   message { message_id, conversation_id, body }
peer   -> server: ack_delivered { message_id }

The client_msg_id matters because mobile networks retry. If the same send arrives twice, the server can recognize it and avoid storing two copies.

The architecture diagram

Chat system architecture Mobile clients keep WebSocket connections through an edge load balancer and gateway pool. Messages are persisted, queued, delivered by workers, and pushed back through gateways. Presence and receipts are separate small services. Key insight: persist once, fan out through queues, push through the open WebSocket. Sender app open socket Recipient app open socket Edge / LB keeps routing WebSocket gateway pool user → gateway heartbeats Presence service short TTL state Chat service validate + assign id Message store Delivery queue absorbs bursts Delivery workers Receipt service delivered / read send message push delivery persist before fan-out worker finds recipient gateway acks/read receipts stay out of the hot message path
The message path is durable; the connection, presence, and receipt paths are separate so noisy signals do not block delivery.

The bottleneck deep-dive

The bottleneck is fan-out. One message becomes delivery work for every recipient, and that work must respect ordering without letting a slow or offline recipient block everyone else.

ChoiceHow it worksTrade-off
Fan-out on writeWhen the sender writes, create delivery jobs for each recipient.Fast recipient delivery and simple unread counts; expensive for very large groups.
Fan-out on readStore one message; recipients fetch unread messages when they open the app.Cheap writes; slower reads and weaker real-time delivery.
HybridUse write fan-out for one-to-one and small groups; use read or batched fan-out for huge groups.Best practical default, but more rules to operate and explain.

For this lesson, choose fan-out on write for one-to-one and small groups. Persist the message first, enqueue delivery per recipient, then let workers push through the gateway that owns each online user's WebSocket. If a user is offline, the job waits or becomes unread state. If a worker retries, message_id and recipient_id make delivery idempotent.

Ordering is the quiet detail to say out loud. Put messages for the same conversation on the same ordering key or queue partition, because systems such as Kafka preserve order inside a partition (Kafka concepts). Cross-conversation global ordering is not worth chasing.

Numbers that matter

FigureWhy it mattersSource
1 open connection per online deviceWebSocket gateways scale by concurrent sockets, not just requests/sec.RFC 6455
100 msA good target for a message to feel instant in the UI.NN/g response limits
10k messages/sec × 1 KB ≈ 10 MB/secBack-of-the-envelope sizing before replication, indexes, and receipts.Derived estimate
~200 µs Redis round tripPresence can live in fast ephemeral storage, but each network hop still costs something.Redis latency guide
1 ordering key per conversationKeep a chat's messages ordered without forcing one global order.Kafka concepts

Practice — answer before you scroll past

1. A chat gateway CPU is fine, but messages arrive in bursts. What absorbs the burst without dropping messages?

2. A user sends one message to a small group. Which fan-out choice makes recipients receive fastest?

3. Why keep presence separate from the message store?

4. A recipient is offline. What should the system do after storing the message?

Your move (tonight)

Redraw the architecture diagram from memory on paper: mobile clients, edge/load balancer, WebSocket gateways, chat service, message store, delivery queue, workers, presence service, and receipt service. Then explain the design out loud in 5 minutes as if to an interviewer.

Your explanation must use the seven-step script and must say the invariant from this page without looking. No LeetCode tonight. For the encode step in your Notion tracker, write one sentence explaining why chat separates persistent connections, queues, presence, and receipts.

One primary source

Read MDN — The WebSocket API after your redraw, as the compare step. Focus on why the open two-way connection changes the shape of the server.