Scale Interview
System DesignHard

Design a Chat App

WhatsApp / Messenger System Design

Overview

A chat app lets people send and receive messages in real time — one-to-one and in groups — like WhatsApp, Messenger, or Telegram. A message you send should reach the other person in well under a second, even when they are on the other side of the world, and it must never silently disappear. If the recipient is offline, the message waits and arrives the moment they come back.

It is labeled "Hard" because the core problem is not storing messages — it is delivering them in real time to a moving target. A normal web app answers a request and forgets the client. A chat app must hold a live connection open to every online user at once (millions of them), know which server each user is currently attached to, and push a message to exactly that server the instant it arrives. Layer on top of that: messages must arrive in order within a conversation, you must never lose one, the same message must not be delivered twice, and a 10,000-person group turns one send into thousands of deliveries (called fan-out).

In this walkthrough you'll scope the app, size it, model the data, design the client contract (including the real-time channel, which is not plain REST), draw the delivery paths, and examine real-time routing, delivery guarantees and ordering, and group fan-out.

Functional requirements

  • Send and receive 1:1 messages. Two users exchange messages in a private conversation, delivered in real time.
  • Send and receive group messages. A message to a group reaches every member.
  • Delivery and read receipts. The sender sees when a message was delivered to the recipient's device and when it was read (the "double tick" in WhatsApp).
  • Message history. A user can scroll back and load older messages in a conversation, including on a fresh device.

Out of scope (state it, so you don't burn time on it): voice/video calls, the internals of end-to-end encryption (we mention where it fits but not the key-exchange protocol), and deep media handling (large file upload/transcoding is its own subsystem). We keep real-time delivery, ordering, receipts, and fan-out firmly in scope — they are the whole problem.

Non-functional requirements

  • Latency. A message should reach an online recipient in well under a second end-to-end — target < 500 ms p99. This is the headline property and it drives the persistent-connection design.
  • Durability. Once the server accepts a message, it must survive a crash and eventually reach the recipient, even if that takes hours (recipient offline). We persist before we acknowledge — no message is ever lost.
  • Consistency (ordering). Messages in one conversation must appear to all participants in a single consistent order. Across unrelated conversations, order does not matter.
  • Availability. The service should stay up and accept messages even when individual servers fail. Briefly delayed delivery is acceptable; lost messages are not.
  • Scalability. Connections, message writes, and fan-out all grow with users, so every layer must scale by adding machines, not by buying a bigger one.

Estimations

State assumptions; the goal is to justify the persistent-connection layer, the write-heavy store, and the fan-out design — not to be exact.

Rounded assumptions

  • Daily active users (DAU): 500M. At any peak moment, assume ~100M are online with a live connection open.
  • Messages per user per day: 4020B messages/day.
  • Average over a day: 20B / 86,400s ≈ ~230K messages/sec. Round up to ~250K messages/sec for headroom.
  • Peak is spikier than average (mornings, evenings, events). Assume peak ≈ 3× average → ~750K messages/sec.
  • Groups: average group ~50 members; large groups up to ~10K members. One message to a 10K group is up to 10K deliveries — fan-out, not one write.

How the numbers affect the design

Signal from the numbers Design decision
~100M users online at once A layer of connection servers holding millions of long-lived connections each — not request/response web servers.
~250K–750K messages/sec, write-heavy A write-optimized, horizontally partitioned message store, not a single SQL database.
Reads are "fetch recent messages by conversation" Partition the store by conversation and order by time, so a chat opens with one sequential read.
Recipient may be offline A store-and-forward path: persist, then push a notification and deliver on reconnect.
Group message = up to 10K deliveries Treat fan-out as its own problem; one send is not one write.
A user → which server they're connected to changes constantly A routing registry mapping user → connection server, kept fresh.

Storage

  • Per message: id (~16 bytes), conversation id (~16 bytes), sender id (8 bytes), sequence number (8 bytes), timestamp (8 bytes), status (1 byte), body (~200 bytes avg text) ≈ ~260 bytes. Round to ~300 bytes with overhead.
  • 20B messages/day × 300 bytes ≈ ~6 TB/day of raw message data, before replication. Round to ~6 TB/day → ~2 PB/year. This is large and grows forever, which is another reason for a partitioned, write-optimized store with tiered/cold storage for old messages.

Caution

Do not model this as "store messages in one big SQL table." The combination of ~750K writes/sec at peak, petabyte-scale growth, and an access pattern of "fetch recent messages in this conversation" is exactly what a partitioned wide-column store is built for. A single relational database becomes the bottleneck on writes long before storage runs out.

Core entities / data model

Entity Key fields
User user_id (PK), name
Conversation conversation_id (PK), type (1:1 or group), created_at; for groups: name, owner
Membership (conversation_id, user_id), role, joined_at — who is in each conversation
Message message_id, conversation_id, sender_id, seq, timestamp, body, status
MessageStatus (message_id, user_id)sent / delivered / read — per-recipient receipt state

The Message store is the heavy one. It is partitioned (sharded) by conversation_id and ordered by seq (a per-conversation sequence number). That layout makes the most common read — "give me the last 30 messages in this chat" — a single sequential scan on one partition, with no cross-machine join. This is why a wide-column / NoSQL store partitioned by conversation (e.g. Cassandra, or a similar log-structured store) fits better than a single relational table. One caveat that Deep Dive 2 returns to: a leaderless store like Cassandra has no single per-partition writer, so it can't allocate a contiguous seq on its own — you'd use its lightweight transactions (a compare-and-set insert), an external sequencer service, or a store that does give per-partition ordering natively (for example, a per-conversation Kafka log, or a sharded relational database).

The per-user inbox is a separate idea worth naming. Beyond the durable message log, each user has a notion of "messages waiting to be delivered to me" — an undelivered queue the connection server drains when the user is online or comes back. In small systems this is just "messages in my conversations with seq greater than what my device last acknowledged"; in large systems it is often a dedicated per-user inbox/queue so delivery does not rescan whole conversations.

Note

Separate the durable conversation log (the source of truth, partitioned by conversation) from the delivery/inbox state (what each recipient device has and hasn't received yet). The first answers "what was said"; the second answers "what still needs to reach this device." Conflating them makes both ordering and offline delivery harder to reason about.

Client contract

A chat app is not a normal REST API. Sending and fetching history are ordinary request/response calls, but receiving a message can't be a request — the client doesn't know when a message will arrive, so it can't ask for it at the right moment. Instead the client opens one long-lived connection and the server pushes messages down it. That persistent channel is the heart of the contract, so we describe it explicitly alongside the REST endpoints.

Real-time channel (persistent connection)

Client opens a WebSocket:  wss://chat.example.com/connect
  → authenticates (token), then keeps the connection OPEN

Client → server events:
  { "type": "send", "client_msg_id": "<uuid>", "conversation_id": "...", "body": "..." }
  { "type": "read", "conversation_id": "...", "up_to_seq": 1408 }
  { "type": "ping" }                          (heartbeat to keep the connection alive)

Server → client events (pushed, no request):
  { "type": "message", "conversation_id": "...", "seq": 1409, "sender_id": "...", "body": "..." }
  { "type": "ack",     "client_msg_id": "<uuid>", "message_id": "...", "seq": 1409 }
  { "type": "receipt", "message_id": "...", "user_id": "...", "status": "delivered" }

The client opens one WebSocket when the app starts and keeps it open. Both sides send small JSON events over it. The key difference from REST: the server sends message and receipt events whenever they happen, with no matching request — that is how real-time receiving works. A periodic ping (heartbeat) keeps the connection alive and lets the server detect a dead connection quickly.

Send a message (over the channel, with an HTTP fallback)

Over WebSocket:  { "type": "send", "client_msg_id": "<uuid>", "conversation_id": "...", "body": "..." }
Or HTTP:         POST /api/conversations/{id}/messages
Body:    { "client_msg_id": "<uuid>", "body": "..." }
Returns: 201 { "message_id": "...", "seq": 1409, "timestamp": "..." }

Send a message into a conversation. The client_msg_id is a client-generated unique id (an idempotency key): if the network drops and the client resends, the server recognizes the same client_msg_id and returns the original result instead of storing the message twice. The server assigns the authoritative seq and message_id.

Fetch history (cursor pagination)

GET /api/conversations/{id}/messages?before_seq=1409&limit=30
Returns: 200 { "messages": [ ... 30 messages, seq 1379..1408 ... ], "next_before_seq": 1379 }

Load older messages for the scrollback view, or sync a fresh device. It uses cursor pagination on seq rather than page numbers, because new messages arrive constantly and offset-based pages would skip or repeat rows. Reads hit the message store partitioned by conversation, so this is one sequential read.

List conversations

GET /api/conversations
Returns: 200 { "conversations": [ { "conversation_id": "...", "last_message": {...}, "unread_count": 7 }, ... ] }

Load the chat list with each conversation's last message and unread count — what the app shows on launch.

High-level architecture

Three flows matter: sending a message when the recipient is online (real-time path), delivering when the recipient is offline (store-and-forward path), and group fan-out. The cast of components is shared across them:

  • Connection servers (the gateway layer). Each holds a large number of long-lived WebSocket connections (e.g. ~500K–1M per server). Their only job is to keep connections open and shuttle events to and from connected clients. They are the part that makes "millions online at once" possible.
  • Routing registry. A fast lookup (e.g. Redis) mapping each user to the connection server(s) their devices are attached to (a user is usually online on several devices at once — see Deep Dive 1). Updated on connect/disconnect. This is how one server finds where another user is.
  • Message service + message store. Validates and persists each message durably, assigns the sequence number, and triggers delivery. The store is partitioned by conversation.
  • Push-notification service. For offline recipients, sends a wake-up through the platform's push system (APNs/FCM).

Why split the connection layer from the Message Service — instead of one "chat server" that both holds the socket and persists the message? At small scale one combined server is fine and simpler; the split pays off at scale because the two jobs have opposite properties. Holding sockets is stateful and connection/memory-bound — it scales with how many users are online (~100M) and a server "owns" its clients, so you can't restart it without dropping their connections. Processing a message is stateless and throughput-bound — it scales with messages/sec and any instance can handle any message. Keeping them separate lets each scale on its own axis, lets you redeploy the stateless Message Service freely without dropping live connections, and shrinks the blast radius of a crash (a dead connection server just triggers reconnects — see the note below).

1. Real-time delivery path (recipient online)

The interesting case: Alice and Bob are connected to different connection servers, and Alice sends Bob a message.

flowchart LR
    Alice[Alice's client] -->|"send (WebSocket)"| GWA[Connection Server A]
    GWA -->|"persist + assign seq"| MSG[Message Service]
    MSG --> Store[(Message Store - by conversation)]
    MSG -->|"who has Bob? lookup"| Reg[(Routing Registry)]
    Reg -->|"Bob is on Server B"| MSG
    MSG -->|"forward message"| GWB[Connection Server B]
    GWB -->|"push (WebSocket)"| Bob[Bob's client]
    Bob -->|"client ack / delivered receipt"| GWB
    GWB -->|"delivered receipt"| MSG
    MSG -->|"receipt forwarded to Alice"| GWA
  1. Alice's client sends the message over its open WebSocket to Connection Server A.
  2. Server A hands it to the Message Service, which persists it to the durable store and assigns the conversation's next seqbefore acknowledging, so an accepted message is never lost.
  3. The Message Service looks up Bob in the routing registry and learns he is connected to Connection Server B.
  4. It forwards the message to Server B, which pushes it down Bob's open WebSocket.
  5. Bob's client acknowledges that it received and stored the message. Server B reports that delivered receipt back; the Message Service forwards it to Alice (via Server A), and Alice's client shows the "delivered" tick.

Note

The connection servers are deliberately "dumb pipes": they hold connections and move events. All the durable logic (persistence, sequencing, receipts) lives in the Message Service, so a connection server can die and its clients simply reconnect to another one without losing messages.

2. Store-and-forward path (recipient offline)

If Bob has no open connection, you can't push to him — so you persist and wake him later. This is the store-and-forward pattern.

flowchart LR
    Alice[Alice's client] --> GWA[Connection Server A]
    GWA --> MSG[Message Service]
    MSG --> Store[(Message Store)]
    MSG -->|"Bob not connected"| Reg[(Routing Registry)]
    MSG -->|"queue as undelivered"| Inbox[(Bob's inbox / undelivered)]
    MSG -->|"wake the phone"| Push[Push Service]
    Push --> APNs[APNs / FCM]
    APNs -.->|"notification"| Bob[Bob's phone]
    Bob -.->|"opens app, reconnects"| GWC[Connection Server C]
    GWC -->|"drain undelivered (seq > last acked)"| MSG
  1. Steps 1–2 are the same: Alice's message is persisted and sequenced.
  2. The routing lookup finds Bob is not connected. The message stays durable in the store and is recorded in Bob's undelivered inbox.
  3. The Message Service asks the push service to send a wake-up notification through APNs/FCM.
  4. When Bob opens the app, his client connects to whichever connection server is nearest (Server C) and asks for everything with seq greater than what his device last acknowledged.
  5. The Message Service drains those undelivered messages to Bob in order, and delivered receipts flow back to Alice.

Warning

Push notifications through APNs/FCM are best-effort, not guaranteed or ordered — treat them only as a "wake up and reconnect" hint. The real delivery is the client reconnecting and pulling undelivered messages from the durable store. Never treat the push payload itself as the authoritative copy of the message.

3. Combined architecture

Putting the paths together:

flowchart LR
    Alice[Alice] --> GWA[Connection Server A]
    Bob[Bob] --> GWB[Connection Server B]
    GWA --> MSG[Message Service]
    GWB --> MSG
    MSG --> Store[(Message Store - by conversation)]
    MSG <-->|"who is online, on which server"| Reg[(Routing Registry)]
    MSG -->|"online: forward to recipient's server"| GWB
    MSG -->|"offline: queue + wake"| Inbox[(Undelivered inbox)]
    MSG --> Push[Push Service]
    Push --> APNs[APNs / FCM]
    Members[(Membership store)] -.->|"group: who are the recipients"| MSG

The Message Service is the hub: it persists every message, decides per recipient whether to forward (online) or queue + wake (offline), reads membership to know who the recipients are (one person for 1:1, many for a group), and keeps the routing registry in sync as users connect and disconnect.

Tip

Lead with the online 1:1 path, then add the offline path, then groups. Interviewers want to see you handle the happy path first, then layer in the harder cases — not jump straight to a 10,000-member group.

Deep dives

1. Real-time delivery and connection routing

The central mechanism is the persistent connection. A user keeps one WebSocket open to a connection server; the server can push to that user any time. The hard question: when Alice (on Server A) sends to Bob, how does Server A get the message onto Bob's connection, which lives on Server B? Below are the two standard approaches — first a pub/sub bus that avoids a routing map entirely, then the central registry that usually wins at scale.

Pub/sub bus between all gateways (no central registry) (situational)

Skip the lookup. Every connection server subscribes to a pub/sub channel, and to deliver to Bob you publish the message to a channel (e.g. one channel per user, user.bob). Whichever server Bob is connected to is subscribed to that channel and pushes the message; the others ignore it.

flowchart LR
    Alice[Alice's client] -->|"send"| GWA[Connection Server A]
    GWA -->|"publish to<br/>user.bob"| Bus[(Pub/Sub Bus)]
    Bus -->|"deliver to<br/>subscribers"| GWB["Connection Server B<br/>(subscribed to user.bob)"]
    Bus -.->|"ignored"| Other[Other servers]
    GWB -->|"push"| Bob[Bob's client]
    style Bus fill:#fef9c3,stroke:#ca8a04,color:#713f12
  • Pro: no separate routing map to keep consistent — subscription is the routing, so reconnects and server moves are handled by re-subscribing. Simpler delivery code.
  • Con: a per-user channel for ~100M online users is a lot of subscriptions to manage; if instead you use coarse channels (e.g. a few broad topics), every server receives every message and filters it, which wastes bandwidth and CPU at this scale. The bus itself becomes a throughput bottleneck and a single point to scale carefully.
  • Verdict: clean for smaller systems or coarse routing, but at WhatsApp scale the central registry + direct forward usually wins on efficiency. Pub/sub's one real edge is multi-device: publishing once to user.bob reaches whichever server holds each of Bob's devices, so you never track which server each device sits on — the subscription is the routing. A common hybrid captures both: a registry for direct 1:1 routing, plus pub/sub for fanning a single message out to all of one user's devices.

The registry approach is the one that usually wins at scale, and it's what you should lead with in an interview.

Central routing registry + direct forward (recommended)

Keep a shared map (e.g. in Redis), updated whenever a user connects or disconnects. A real user is online on several devices at once — phone, web, desktop — so the map is not user_id → one server but user_id → a **set** of (device, connection server) entries, one per live connection. To deliver to Bob, look him up, find every device he has online, and forward the message directly to each of those connection servers, which push it to that device. Every delivery thus fans out to all of the user's online devices, not just one.

flowchart LR
    Alice[Alice's client] -->|"send"| GWA[Connection Server A]
    GWA --> MSG[Message Service]
    MSG <-->|"lookup(bob) =<br/>{ server-B, server-D }"| Reg[("Routing Registry<br/>bob → (phone, server-B),<br/>(web, server-D)")]
    MSG -->|"forward"| GWB[Connection Server B]
    MSG -->|"forward"| GWD[Connection Server D]
    GWB -->|"push"| Phone[Bob's phone]
    GWD -->|"push"| Web[Bob's web]
    style Reg fill:#dcfce7,stroke:#16a34a,color:#14532d
  • Pro: delivery is a single targeted hop to exactly the right server — efficient, low latency, and it scales because each message touches only the two servers involved.
  • Con: you must keep the registry fresh and correct; a stale entry (Bob moved servers, or his connection died) sends the message to the wrong place. You handle this with short TTLs, connection heartbeats, and treating "forward failed" as "recipient offline" (fall back to store-and-forward). The dangerous case is subtler: Bob moved servers but the registry still points at a stale, live-looking socket on his old server, so the forward "succeeds" into a dead socket and the message is silently dropped — no error to fall back on. The backstop is durability: the message is already persisted with a seq, so when Bob's device reconnects and pulls "everything after my last acked seq" it backfills anything a misroute dropped. The durable log — not the registry — is the correctness backstop for routing errors.
  • Verdict: the standard approach at scale — precise, cheap per message, and the registry is small and fast.

Caution

Do not propose having every connection server poll a database to find new messages for its users. At millions of connections and hundreds of thousands of messages per second, polling melts the database. Delivery must be push: the message is actively forwarded to the recipient's connection server.

Detecting disconnects. Connections die silently (phone loses signal, app backgrounded). Heartbeats (the periodic ping) let the connection server notice a dead connection within seconds and update the registry to "offline," so the next message for that user goes to store-and-forward instead of a dead socket.

Reconnect thundering herd. When a connection server dies or a network partition heals, the ~1M clients it held all try to reconnect at the same instant — a thundering herd that hammers the registry and message store in one spike. Two mitigations: have clients reconnect with randomized exponential backoff (add jitter so they don't all retry on the same schedule), and when taking a server out of rotation, drain its connections gradually onto other servers rather than a hard cutover that dumps everyone at once.

2. Delivery guarantees, ordering, and receipts

Three correctness properties live here: don't lose messages, don't deliver duplicates, and keep order within a conversation.

At-least-once delivery + client dedup. The system aims for at-least-once delivery: persist before acknowledging, and retry delivery until the recipient confirms. Retries can cause duplicates (the ack was lost, so the server re-sends). The fix is to deduplicate on the recipient's client using a server-assigned id — the delivered message event carries (conversation_id, seq) (the same identity as the server's message_id), assigned once at persist, so every redelivery reuses it and the client ignores a seq it has already stored. Note this is the server's id, not the sender's client_msg_id: client_msg_id is the write-path idempotency key that stops a sender's retry from creating a second message in the first place (see the API section) — which is exactly what guarantees there's a single server id to dedup on here. So the guarantee is "at-least-once on the wire, exactly-once as the user sees it."

 Server pushes message #1409 to Bob
 Bob's "delivered" ack is lost in the network
 Server retries → pushes #1409 again
 Bob already has #1409 (same message_id) → drop the duplicate, re-send ack

Warning

Do not aim for exactly-once delivery over the network — it is effectively impossible with unreliable connections. Aim for at-least-once plus idempotent handling (dedup by client_msg_id on send — the sender has no server message_id yet, which is exactly why the client generates one — and by the server message_id on receive), which gives the user a single copy.

Per-conversation ordering via sequence numbers. Each conversation has a monotonically increasing seq, assigned by the server when it persists the message. Clients render messages sorted by seq, and request "everything after my last seq" on reconnect. This gives one consistent order for all participants without needing globally synchronized clocks (wall-clock timestamps drift between devices and can't be trusted for ordering). Ordering only needs to hold within a conversation, which is exactly why partitioning the store by conversation works — the seq is assigned per partition.

But "assign the next seq" hides a hazard when two users send to the same conversation at the same moment. Naively, each sender reads the current max seq, adds one, and writes — a classic read-modify-write race, where both read the same value and write the same seq, so one message overwrites the other or both collide. The fix is a single-writer serialization point per conversation: since the store is already partitioned by conversation_id, the partition leader for that conversation is the one place that hands out seq. First, keep monotonic and contiguous straight: a plain atomic counter increment (e.g. Redis INCR, then write the message) gives you monotonicity — numbers only go up — but not contiguity. If you reserve seq N from the counter and the message write then fails, N is burned: the counter has moved on, so N is a permanent gap that no message will ever fill. Contiguity requires allocating seq as part of the durable commit — a compare-and-set insert ("write at seq N only if N is still free") that either commits N and the message atomically or allocates nothing — not a separate counter increment that can succeed while the write fails. Because only one writer allocates numbers this way for a conversation, seq stays monotonic and gap-free no matter how many senders race.

Where does that single-writer allocator live? It depends on the store. A store with a genuine per-partition leader (for example, a per-conversation Kafka log, or a sharded relational database) can do the allocate-on-commit CAS-insert directly. A leaderless store like Cassandra has no such single writer, so the "partition leader hands out seq" model doesn't come for free there — you get the atomic CAS-insert only through its lightweight transactions (Paxos, which adds a coordination round-trip), or you move allocation out to a separate sequencer service that owns seq per conversation. Naming a leaderless store and a partition-leader atomic allocator in the same breath is a contradiction to watch for.

Contiguity is a design choice, not a law: it's what enables the simple gap-detection below, so systems that want it pay for the allocate-on-commit path. Many production chat systems don't guarantee globally contiguous per-conversation seq at all — they lean on monotonic seq plus per-recipient acknowledgment cursors to know what's been delivered, and detect loss another way. If you choose contiguity, commit to the allocate-on-commit discipline; if you don't, don't build gap-detection on an invariant you aren't actually holding.

Note

If you've chosen contiguity, it's what lets a reconnecting client gap-detect: if it has seq 1408 then 1410, it knows 1409 is missing and re-pulls it. This only holds because allocate-on-commit means an aborted write never burns a number — either N is never allocated (the compare-and-set simply didn't commit) or the slot is filled with a tombstone/retry so the sequence has no permanent hole. Treat a real gap as "I'm missing a message," never as "that number was thrown away." (If your store can't promise contiguity, don't rely on this — fall back to per-recipient acked cursors to decide what to re-pull.)

Note

You don't need a global order across all conversations — only within each one. That local requirement is what lets you shard the message store by conversation and assign seq independently per shard. Trying to enforce a single global order across all messages would be an unnecessary, unscalable bottleneck.

A client normally notices a gap only when the next message arrives — which could be a while in a quiet chat. To shrink that window, the server can piggyback the conversation's current seq on the heartbeat ping: a client that's fallen behind sees the gap within one heartbeat and re-pulls immediately, instead of waiting for new traffic to reveal the miss.

The receipt state machine. A message moves through three states, each a separate event back to the sender:

flowchart LR
    Sent["sent<br/>(one tick)<br/>server has persisted it"] --> Delivered["delivered<br/>(two ticks)<br/>reached recipient's device"]
    Delivered --> Read["read<br/>(two blue ticks)<br/>recipient opened the conversation"]

sent is set when the Message Service persists the message and acks the sender. delivered should mean the recipient's client/device received and acknowledged the message, not merely that a connection server wrote bytes to a socket. A server push can fail after the write, so the safer contract is: push the message, wait for the client ack, then mark it delivered (or retry/store-and-forward if the ack never arrives). read fires when the recipient's app reports the conversation was viewed (the client sends a read event with up_to_seq). For groups, delivered/read are tracked per member (the MessageStatus entity), and the UI typically shows "read by 8/10" rather than a single tick.

Multi-device adds a second dimension to receipts. Because a user is online on several devices, each device tracks its own last-acked seq cursor — the phone and the web client pull and acknowledge independently, and the server replays to each device from its cursor. Receipts then need a device dimension: delivered usually means "delivered to any of the user's devices" (one tick is enough — the message reached the person), while read is naturally per-device (you read it on your phone, not yet on the web). A common, simple rule is to mark the message read for the user as soon as any device reports reading it.

Store-and-forward ties it together. Because messages are persisted before acknowledgment and an offline recipient has an undelivered inbox, no message is lost across a disconnect. On reconnect the client says "my last seq is 1408," and the server replays 1409 onward in order — combining durability, ordering, and the delivery guarantee in one reconnect flow.

This also reconciles the two offline sources you'll see below. On reconnect a device pulls from both: (a) its per-user inbox, for 1:1 and small-group messages that were fanned out to it on write, and (b) for each large group it belongs to, any message with seq greater than its last-acked cursor read directly from that conversation's log (fan-out on read). The per-conversation last-acked seq cursor is what unifies the two — it's the same "what have I already received" marker in either case. The inbox is just an optimization: it spares the device from scanning every conversation it belongs to, which is why large groups skip it and pull from the log on demand instead.

3. Group messaging fan-out

A 1:1 message has one recipient. A group message has many, so one send becomes many deliveries — fan-out. How you do that fan-out depends on group size.

Small groups — fan-out on write (write to each member's inbox) (default case)

When a message is sent to a small group, the Message Service immediately resolves the membership list and delivers to each member the same way as 1:1: online members get a direct forward to their connection server; offline members get a push + an undelivered-inbox entry. The conversation log is still stored once (by conversation_id); the fan-out is the delivery, one per member.

flowchart LR
    Alice[Alice sends<br/>'dinner at 7'] --> MSG["Message Service<br/>persist once, seq=512"]
    MSG -->|"fan-out"| Bob["bob (online)"]
    MSG -->|"fan-out"| Carol["carol (offline)"]
    Bob -->|"forward to bob's server"| BobPush[push]
    Carol -->|"push notify +<br/>add to inbox"| CarolInbox[(carol's inbox)]
    style MSG fill:#dcfce7,stroke:#16a34a,color:#14532d
  • Pro: simple and fast for small groups; each member's delivery is identical to the well-tested 1:1 path, and the sender gets receipts per member naturally.
  • Con: delivery cost grows linearly with group size — fine for tens of members, expensive for thousands.
  • Verdict: the right default for typical groups (a few to ~hundreds of members).
Large groups — the fan-out cost problem (needs special handling)

For a 10,000-member group, one send becomes up to 10,000 deliveries. If a busy large group gets many messages per second, the fan-out multiplies the system's internal message rate dramatically — a real bottleneck.

 Group with 10,000 members, 5 messages/sec
   → 10,000 × 5 = 50,000 deliveries/sec  for ONE group
 A few such groups can dominate the delivery layer's load.

Mitigations, all worth naming:

  • Only fan out to online members now; offline members get the message lazily when they reconnect and pull from the conversation log (fan-out on read for the offline tail), instead of writing 10,000 inbox entries up front.

  • Deliver via a per-group channel, not N direct forwards. Above a size threshold, have a large group's online members subscribe to a single chat-level pub/sub channel (chat.<id>); the sender publishes once to that channel and every online member's connection server delivers, instead of the service resolving the membership list and issuing thousands of individual forwards per message. A client subscribes to the channels of its large groups plus its own user channel. When a group crosses the threshold, publish to both the per-member path and the new chat channel for a short window so the switchover loses nothing.

  • Batch and rate-limit fan-out work through a queue so a hot group can't starve the rest of the system.

  • Cap or downgrade receipts for huge groups — tracking per-member delivered/read for 10,000 people is itself heavy, so large groups often show limited or no read receipts.

  • Apply backpressure to slow consumers. Each connection server keeps a bounded outbound buffer per connection. On a hot 10K group, one slow device can't drain fast enough; rather than buffer its messages without limit (which would exhaust the server's memory and hurt every other client on it), drop that one recipient back to store-and-forward when its buffer overflows. The client pulls the missed messages on reconnect anyway, so nothing is lost — you just stop letting one slow consumer back up the whole server.

  • Pro: keeps very large groups feasible by not paying the full fan-out cost for every member on every message.

  • Con: more complex — you now mix fan-out-on-write (online) with fan-out-on-read (offline), and receipts are degraded.

  • Verdict: necessary above some size threshold; below it, fan-out on write to every member is simpler and fine.

The key contrast with 1:1: a 1:1 message is one persist + one delivery, so fan-out never matters. A group message is one persist + N deliveries, and the design changes qualitatively once N gets large — which is exactly the point an interviewer is probing.

Failure modes

Every component here is recoverable because the durable, sequenced message log is the single source of truth — a message is persisted (with its seq) before the sender is acked, so anything downstream can be re-pulled from it.

  • The send fails before the message is persisted (client loses signal, or the sender's connection server or the Message Service dies before the write). The server never acked it, so there is nothing in the log yet — and here the guarantee shifts to the sender's client, which is the source of truth until it's acked. The client holds the message in a durable local outbox shown as pending, and retries with the same client_msg_id until it receives an ack (then marks it sent). Retrying is safe even if an earlier attempt actually did persist — the idempotency key dedups it, so no duplicate. If retries keep failing it surfaces "failed to send" with a retry option, never a silent loss. This is the pre-persistence half of durability; persist-before-ack covers everything after the server accepts the message.
  • Message Service / write tier down. A send that can't be persisted is simply not acked, so the sender's client retries — idempotently by client_msg_id, so recovery creates no duplicate message. Run the service replicated and partitioned by conversation_id so losing one node stalls only its conversations briefly, not the whole app.
  • Message store node fails. The store is replicated (quorum writes), so a message that was acked survives a node loss and reads fail over to a replica. It's the authoritative log; the serving path, per-user inboxes, and routing registry are all derived from it and rebuildable if lost.
  • Routing registry stale or down. The registry is a cache, not the source of truth (Deep Dive 1): a failed forward is treated as "recipient offline" → store-and-forward, and a silent misroute into a dead-but-live-looking socket is caught by durability — the recipient's reconnect pulls everything after its last-acked seq and backfills whatever the misroute dropped.
  • Recipient's connection server dies mid-delivery. The push is never acked, so the message is retried or falls back to store-and-forward; on reconnect the client asks for "everything after my last seq" and gets it in order (Deep Dive 2). A server death also triggers a reconnect thundering herd, handled with jittered backoff and gradual connection drain (Deep Dive 1).

The through-line: connection servers, the routing registry, inboxes, and receipts are all derived and recoverable — the one thing that must never lose a write is the persisted, sequenced log, which is exactly why we persist before we acknowledge.

Tradeoffs & bottlenecks

  • Persistent connections vs request/response. Holding ~100M live WebSocket connections is the defining cost: connection servers are stateful (they "own" their clients), need careful load balancing on connect, and must hand clients off cleanly when a server is drained or dies. This is the price of sub-second push delivery.
  • At-least-once + dedup vs exactly-once. True exactly-once over unreliable networks is impractical, so you accept possible duplicates and remove them by message id. Simpler and robust, at the cost of dedup logic on both ends.
  • Routing registry freshness. The user → server map must stay fresh (heartbeats, TTLs); staleness causes misroutes that fall back to store-and-forward. The alternative — a pub/sub bus — trades the registry for per-user subscription overhead.
  • Fan-out on write vs on read. Writing to every member's inbox is simple but costly for big groups; reading from the shared log lazily is cheaper for offline members but more complex and weakens receipts. Most systems use both, chosen by group size.
  • Ordering scope. Per-conversation ordering via seq is cheap and shardable; a global order would be a bottleneck and isn't needed. Naming this scope is the key insight.
  • Hot conversations / hot servers. A massive active group, or many heavy users on one connection server, creates a hotspot. Partition by conversation, rate-limit fan-out, and rebalance connections to contain it.

Extensions if asked

If the interviewer wants to push further, add only the extension that changes the design discussion (these are standalone topics; there's no dedicated guide for each yet):

  • Media and attachments. Don't send large files over the WebSocket. Upload the file to blob storage, get back a URL/reference, and send a small message containing that reference; the recipient downloads separately. Media upload/transcoding is its own subsystem.
  • End-to-end encryption. The client encrypts the body so only recipient devices can read it; the server relays ciphertext and never sees plaintext. This complicates server-side features (search, group key management) and is its own deep protocol topic.
  • Voice and video calls. Real-time audio/video uses a different transport (peer-to-peer media with a signaling channel), separate from text messaging.
  • Typing indicators and presence at scale. "Alice is typing…" and online/last-seen are high-frequency, low-value events; broadcast them over pub/sub with aggressive throttling, and never persist them durably.
  • Multi-device sync. One account on phone + laptop + web means delivering to several connections per user and keeping read state consistent across them.

What interviewers look for & common mistakes

What interviewers usually reward:

  • Persistent connections (WebSocket) for receiving, with a clear explanation that receiving cannot be plain REST — the server must push.
  • A routing mechanism (central user → server registry + direct forward, or a pub/sub bus) so a message reaches the recipient's current connection server, with the tradeoff named.
  • Store-and-forward for offline users: persist before acknowledging, wake via APNs/FCM, deliver on reconnect — and treating push as a hint, not the message.
  • At-least-once delivery with client dedup by message id, and per-conversation ordering via sequence numbers (not wall-clock timestamps).
  • The sent → delivered → read receipt state machine, tracked per member for groups.
  • Group fan-out handled as its own problem, with small-group fan-out-on-write vs large-group cost mitigations.

Before you finish, do a quick mistake check:

  • Did you use persistent connections for receiving, instead of polling or REST?
  • Did you explain how a message routes to the recipient's current connection server?
  • Did you persist messages durably before acknowledging, so nothing is lost?
  • Did you handle offline recipients with store-and-forward + push, not assume everyone is online?
  • Did you make sends idempotent (client message id) and dedup duplicates on receive?
  • Did you order messages per conversation with sequence numbers, not trust device clocks?
  • Did you treat large-group fan-out as a distinct, harder case than 1:1?

Practice this live

Run this exact question with our AI voice interviewer and get feedback.

Start the interview →