Week 26: Case Study V — Design a Distributed Chat System

The last case study before the capstone pulls together the thread running through Weeks 15–23: Week 20's real-time connection layer, delivered with a correctness guarantee this week defines precisely, fanned out to a group efficiently, and stored so years of message history stay retrievable without scanning it all on every app open. A chat system is deceptively simple to describe and genuinely hard to get exactly right.

Module 23 of 24 Week 26 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Define exactly-once-delivered, in-order messaging and how it's actually achieved
  • Design group chat fan-out that scales to large groups
  • Design message storage that supports fast, paginated history loading

1. Requirements & Estimation

requirements & estimation — chat system
Functional:
- Send/receive 1-on-1 and group messages
- Messages delivered in order, exactly once, even if the
  recipient is briefly offline
- Load message history when opening a conversation

Non-functional:
- Scale: 100M daily active users, ~50 messages/user/day
- Groups up to 500 members
- Delivery latency: under 200ms when both parties are online
- Messages must never be silently lost or duplicated

Estimation:
  Messages sent: 100,000,000 * 50 / 86,400s ≈ 58,000 msg/sec
                 average, peak ≈ 58,000 * 3 ≈ 174,000 msg/sec

  Group fan-out multiplier: a message to a 500-member group
  becomes up to 500 individual deliveries -- group messages
  are a small fraction of total messages but a LARGE fraction
  of total deliveries (Section 3)

Two requirements in the functional list deserve special attention because they're genuinely hard, not just a checkbox: "exactly once" and "in order" are both real engineering problems, not defaults a naive design gets automatically. Section 2 builds the mechanism that actually achieves them.

2. Deep Dive: Delivery Guarantees & Ordering

Week 20 built the WebSocket connection layer for pushing messages live; it didn't address what happens when the recipient is offline, or what "exactly once, in order" actually requires mechanically.

per-message sequence numbers
Every message gets a monotonically increasing sequence number,
scoped per conversation:

  Conversation "user-A_user-B":
    msg 1: "hey"           seq=1
    msg 2: "you there?"    seq=2
    msg 3: "nvm found it"  seq=3

Client tracks the highest seq it has successfully received
and acknowledged (Section 4's storage holds the durable log).

On reconnect after being offline:
  Client sends: "I last saw seq=1 in this conversation"
  Server responds: "here's everything after seq=1" --> delivers
  seq=2 and seq=3, in order, exactly once

This is the same underlying pattern as Week 20's presence heartbeat, applied to message delivery instead of liveness: the client doesn't rely on the server "just knowing" what it missed — it explicitly states the last sequence number it saw, and the server fills the exact gap. This sidesteps the ambiguity of "did that message actually arrive" that a fire-and-forget push (Week 20's basic pub/sub example) leaves unanswered — sequence numbers turn delivery into a resumable, verifiable stream instead of a best-effort broadcast.

"Exactly once" specifically requires the client to deduplicate on its end too: if a network hiccup causes the server to redeliver seq=2 (unsure whether the client's earlier acknowledgment actually arrived), the client discards it as already-seen rather than displaying it twice — the same idempotency principle from Week 14's fan-out pipeline and Week 20's tip about lost messages, now applied to the receiving side instead of the sending side.

"Exactly once" is really "at-least-once plus deduplication"

True exactly-once delivery over an unreliable network is, strictly speaking, impossible to guarantee purely at the network layer — what real systems build instead is at-least-once delivery (the server keeps redelivering until acknowledged) combined with client-side deduplication by sequence number, which produces the same user-visible effect. Naming this distinction explicitly is a stronger answer than claiming a network-level guarantee that doesn't actually exist.

3. Deep Dive: Group Chat Fan-out

Section 1's estimation flagged the real scaling concern: a single message to a 500-member group becomes up to 500 individual deliveries — conceptually the same fan-out problem Week 14 solved for a news feed, now applied to a real-time chat context with Week 20's stateful-connection routing layered on top.

group message fan-out
User sends a message to a 500-member group:

1. Message written to durable storage (Section 4) ONCE,
   tagged with the group's conversation ID

2. Fan-out job (Week 8's queue) enqueued: "notify these
   500 members"

3. Fan-out workers look up which connection server (if any)
   each of the 500 members is currently connected to (Week 20)

4. For each ONLINE member: publish to their user channel via
   pub/sub (Week 20) -- their connection server delivers it
   over the open WebSocket, instantly

5. For each OFFLINE member: nothing further needed here --
   Section 2's sequence-number catch-up delivers it the next
   time they reconnect and ask "what did I miss after seq=N?"

The critical design decision, worth stating explicitly: the message is written to storage once, regardless of group size — the fan-out only duplicates the lightweight notification to online members, not the message content itself 500 times over. Offline members don't need an active fan-out delivery attempt at all; Section 2's catch-up mechanism already handles them for free when they reconnect, which is a meaningfully simpler and cheaper path than trying to guarantee live delivery to someone who isn't currently connected.

4. Deep Dive: Message Storage & History

Opening a conversation with years of history needs to load the most recent messages instantly, without ever scanning the entire history — the same performance concern Week 8-9's course covered for API pagination, applied here to a conversation's message log.

storage schema & pagination
messages table, partitioned/sharded by conversation_id
(Week 5's sharding, applied per-conversation so one busy
 group chat's volume doesn't affect an unrelated conversation):

  conversation_id | seq | sender_id | content | timestamp
  ----------------+-----+-----------+---------+----------
  convo_882       | 1   | user_A    | "hey"   | ...
  convo_882       | 2   | user_B    | "hi!"   | ...

Loading history, most-recent-first, paginated:
  SELECT * FROM messages
  WHERE conversation_id = 'convo_882' AND seq < :last_seen_seq
  ORDER BY seq DESC
  LIMIT 50

Each page fetches the next 50 messages older than the last one
already loaded -- an indexed range query, not a full scan,
regardless of how many total messages the conversation has
accumulated over years.

Partitioning by conversation_id keeps each query scoped to one conversation's own data, and the seq-based range query (rather than, say, a timestamp-based one) reuses Section 2's sequence number as a natural, guaranteed-unique pagination cursor — no ambiguity from two messages sharing an identical timestamp, which a pure timestamp-based cursor would have to handle as a separate edge case.

Search over message history is a genuinely separate problem

"Search my messages for 'flight confirmation'" is Week 19's inverted-index problem, not this schema's — the messages table above is optimized for sequential, range-based reads, not free-text search. A complete chat design needs both the storage schema in this section and a separately-maintained search index, kept in sync the way Week 19's tip described, not one system doing both jobs.

5. Hands-on Exercise

Hands-on

Add read receipts to the chat system

Add a "read" receipt feature: a sender sees when their message has been read by the recipient(s).

Requirements:

  1. Design how a read receipt is represented and transmitted, reusing Section 2's sequence-number mechanism — what does the recipient's client send back to mark a message as read?
  2. For a 1-on-1 chat, describe how the sender's client learns a message was read, including what happens if the sender is currently offline when the read receipt is generated.
  3. For a 500-member group, decide what "read" should mean and how it should be displayed — is it feasible or desirable to show all 500 individual read statuses, and if not, what's a better design?
  4. Explain whether read receipts need Section 4's durable, indexed storage the same way messages do, or whether a lighter-weight mechanism is acceptable — justify against what's lost if a read receipt is dropped versus what's lost if a message is dropped.
  5. Identify one privacy consideration this feature introduces that message delivery itself doesn't have.
Hint

For requirement 3: at 500 members, "who specifically has read this" is rarely useful UI — most real chat products show an aggregate ("read by 214") or a small sample rather than an exhaustive per-member list, which is also a much cheaper thing to compute and display than tracking and rendering 500 individual statuses live.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

How does a per-conversation sequence number let a client that was offline for an hour catch up correctly, without the server tracking each client's individual state?

The client itself remembers the last sequence number it successfully received and states it on reconnect ("I last saw seq=1"), and the server simply returns everything after that number from the durable message log. This shifts the "what have I already seen" tracking to the client, so the server doesn't need to maintain bespoke per-client delivery state.

Q2

Why is "exactly-once delivery" more accurately described as "at-least-once delivery plus client-side deduplication"?

True exactly-once delivery can't be guaranteed purely at the network layer over an unreliable connection, since the sender can never be fully certain an acknowledgment was received versus lost. Real systems instead redeliver until acknowledged (at-least-once) and rely on the client discarding already-seen sequence numbers to prevent a visible duplicate — achieving the same user-facing effect through a different mechanism.

Q3

Why is a group message's content stored only once, even though it needs to reach 500 members?

The message is a single fact about the conversation, and duplicating its content 500 times would be pure storage waste with no benefit. Fan-out only needs to duplicate a lightweight notification/pointer to online members; every member (online or offline) ultimately reads the same single stored copy via the sequence-number-based catch-up mechanism.

Q4

Why does using the message's sequence number as a pagination cursor avoid a problem that a pure timestamp-based cursor would have?

A sequence number is guaranteed unique and strictly ordered per conversation, while two messages can share an identical timestamp (especially at high message rates), making a timestamp-only cursor ambiguous about exactly where to resume. The sequence number sidesteps that ambiguity entirely as a clean, unique pagination boundary.