Command Palette

Search for a command to run...

Hectal
PHASE 25Advanced ~31 min· topic 5 of 9

Topic 25.5

Design a Chat Application

In one line

A chat client keeps a live two-way connection, shows your message instantly and then confirms it, orders messages by the server's sequence number rather than by arrival, keeps the scroll where the reader expects, counts unread messages, queues sends while offline and shows who is online and typing.

Think of it like this

Passing notes in class through a helper. You hand your note to the helper and keep a copy that says "sending". When the helper says "delivered", you tick it. If the helper drops it, you send the same note again, and the helper knows not to deliver it twice because it has your name and number on it. Notes from friends may reach you out of order, but each has a number from the teacher, so you can line them up. That is a chat app.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

WebSocket
One long-lived, two-way connection: the server can push messages to the browser at any time.
Heartbeat
A small regular ping that proves the connection is still alive.
Client id
An id the sender makes before sending, used to match the server's reply and avoid duplicates on retry.
Sequence number (seq)
A number the server gives each message in a conversation: 1, 2, 3… It defines the true order.
Ack
Short for acknowledgement: the server's reply saying "I got message X, here is its id and seq".
Delivery state
Where a message is in its journey: sending, sent, delivered, read or failed.
Scroll anchoring
Keeping the message you're reading in the same place on screen when content is added above or below.
Outbox
A saved queue of messages waiting to be sent when the connection comes back.
Presence
Whether someone is online, away or typing right now. Short-lived information, not stored as messages.

Step by step

01Requirements and clarifying questions

Ask: one-to-one or groups (and how big)? Attachments and reactions? Search? End-to-end encryption (then the server can't read or search messages)? Mobile web? Then numbers: messages visible within 500 ms, instant local echo, history paged 50 at a time, conversations with 100k messages, 10 open tabs per user.

02Architecture

One connection manager owns the socket, reconnects and the outbox. Incoming events update a normalised store (conversations and messages by id). History pages come over plain HTTP so they can be cached. Views subscribe to the conversation they show.

Architecturediagram
Rendering diagram…

03Component tree

ChatLayout splits a ConversationList and a ConversationView. The view has a header with presence, a virtualised MessageList (role="log", which screen readers treat as a polite live region) and a Composer.

Component treediagram
Rendering diagram…

04Data model

Messages are stored by clientId until the ack arrives (then also by server id). Each conversation keeps its ordered ids and read position.

chat-types.tswhole filetsx
type DeliveryState = "sending" | "sent" | "delivered" | "read" | "failed";
type Message = {
  clientId: string;          // made by the sender, idempotency key
  id?: string;               // from the server
  seq?: number;              // from the server, defines order
  conversationId: string;
  senderId: string;
  text: string;
  createdAt: string;         // server time, for display only
  state: DeliveryState;
};
type Conversation = {
  id: string; title: string; memberIds: string[];
  lastSeq: number; lastReadSeq: number;   // unread = lastSeq - lastReadSeq
  oldestLoadedSeq: number | null;          // cursor for loading history
};
type Presence = { userId: string; status: "online" | "away" | "offline"; lastSeen?: string; typingIn?: string };

05Interface: socket messages and HTTP

Small, typed events in both directions. resume after a reconnect closes any gap.

chat-protocol.tswhole filetsx
// client -> server
{ type: "send", clientId: "c-7f3a", conversationId: "k1", text: "Hi" }
{ type: "read", conversationId: "k1", upToSeq: 42 }
{ type: "typing", conversationId: "k1" }                 // throttled: max every 3 s
{ type: "resume", conversationId: "k1", afterSeq: 42 }   // after reconnect

// server -> client
{ type: "ack", clientId: "c-7f3a", id: "m901", seq: 43, createdAt: "2024-01-15T10:30:00Z" }
{ type: "message", message: Message }
{ type: "receipt", conversationId: "k1", userId: "u2", deliveredUpTo: 43, readUpTo: 41 }
{ type: "presence", userId: "u2", status: "online" }
{ type: "typing", conversationId: "k1", userId: "u2" }

// HTTP, cacheable history
GET /api/conversations?cursor=...
GET /api/conversations/k1/messages?beforeSeq=43&limit=50  ->  { items: Message[], hasMore: true }

06Deep dive: send, ack, retry

The message lives through these states. The same clientId is used for every attempt, so the server can return the already-stored message if an earlier attempt got through.

Deep dive: send, ack, retrydiagram
Rendering diagram…

07Deep dive: offline, reconnect, many tabs

On offline (or a missed heartbeat) the connection manager marks the socket down and new sends go to the outbox with state sending and a "Waiting for network" note. On reconnect it sends resume with the last seen seq per open conversation, then flushes the outbox in order. With several tabs open, one tab can own the socket and share events with the others through a BroadcastChannel, or every tab connects and the server fans out; either way, merge by id so duplicates are harmless.

Try it yourself

  1. 1

    Two failures

    In the first example, make the server drop two sends (use a counter instead of dropNext). Predict the delivery states shown, then click Retry yourself after the script finishes.

  2. 2

    Prove idempotency

    Change the server so the first attempt stores the message but the reply is lost (save to byClientId before rejecting). Predict: after Retry, how many copies of "Yes, 1 pm?" does the server hold, and which seq does it get?

  3. 3

    Turn the anchoring off

    In the scroll example, delete the if (p.kind === "older") ... line. Predict the top row after loading older messages, then run it. Then also remove overflow-anchor: none and see whether Chromium's built-in scroll anchoring rescues you.

Code & diagrams

Optimistic send, retry with the same client id, ordering by seq New tab

The message shows instantly with a clock, fails, and is retried with the same `clientId`. Ravi's messages arrive as seq 5 then 4, but the list orders by `seq`, so they read in the right order, and the retried message takes seq 6 after them.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

c1 Yes, 1 pm?: sending
c1 Yes, 1 pm?: failed
retry
c1 Yes, 1 pm?: sending
c1 Yes, 1 pm?: sent as seq 6
order: Hey! | Hi Ravi | Lunch? | Canteen | Or 2 pm | Yes, 1 pm?
Scroll anchoring: load history without a jump, don't yank a reader New tab

Each row is 30 px in a 120 px box. Loading 5 older messages adds 150 px above, and the layout effect adds exactly that to `scrollTop`, so the same row stays on top. A reader scrolled into history isn't moved; they get a badge instead. `overflow-anchor: none` turns off the browser's own anchoring so you see the React code doing it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

mounted at bottom, scrollTop 180, top row Message 17
after loading 5 older: scrollTop 330, top row Message 17
reader kept at scrollTop 0, badge: 1 new message ↓
The outbox: queue while offline, flush in ordertsx
// outbox.ts (in a real app the queue is saved to IndexedDB so it survives a refresh)
const queue: OutgoingMessage[] = [];

export function enqueue(msg: OutgoingMessage) {
  queue.push(msg);                 // same clientId on every attempt
  flush();
}

let flushing = false;
export async function flush() {
  if (flushing || !connection.isOpen()) return;
  flushing = true;
  try {
    while (queue.length && connection.isOpen()) {
      const ack = await connection.request({ type: "send", ...queue[0] }, { timeoutMs: 10_000 });
      store.ack(queue[0].clientId, ack);   // state: sent, seq from the server
      queue.shift();                       // only remove after the ack: order is kept
    }
  } catch {
    // stay queued, the reconnect handler calls flush() again
  } finally {
    flushing = false;
  }
}

window.addEventListener("online", flush);
connection.onReconnect(() => { resumeOpenConversations(); flush(); });

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Order by arrival instead of seq

In the first example, change ordered to const ordered = (msgs) => Object.values(msgs); (insertion order, which is arrival order).

terminal
$ Console
── what you'll see ──
c1 Yes, 1 pm?: sending
c1 Yes, 1 pm?: failed
retry
c1 Yes, 1 pm?: sending
c1 Yes, 1 pm?: sent as seq 6
order: Hey! | Hi Ravi | Lunch? | Yes, 1 pm? | Or 2 pm | Canteen

Myth vs fact

Myth

Sort messages by their timestamp.

Fact

Sender clocks are often wrong by seconds or minutes, and two messages can share a millisecond. Use the server's per-conversation sequence number for order; timestamps are only for display.

Myth

A retry might create a duplicate, so don't retry.

Fact

Not if every attempt carries the same client-generated id. The server stores it once and returns the existing message for repeats: that's what an idempotency key is for.

Myth

WebSockets are always required for chat.

Fact

They're the usual choice, but Server-Sent Events plus normal POST requests work well too, and long polling is a fallback where sockets are blocked. Choose by the platform and infrastructure.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Use role="log" on the message list: it's a live region with polite announcements, so screen readers announce new messages without interrupting. Don't announce your own sent messages, and give each bubble an accessible name with sender and time (Phase 18.3).

  • ▸

    A chat view is a reverse infinite list: it starts at the bottom and grows upward. Virtualising it is harder than a feed, because rows have variable heights and the anchor is at the bottom; libraries such as TanStack Virtual support it, but many teams keep a window of a few hundred messages in the DOM and drop the rest (Phase 24.1).

  • ▸

    Typing indicators cost real server traffic in big groups. Throttle sends (one per 3 s while typing), only send when the conversation is open, and drop them entirely above a group size.

  • ▸

    With end-to-end encryption, the server stores ciphertext it can't read. Search, previews and moderation then have to happen on the client, which changes the data model and the API. Ask early in the interview.

Remember this

  1. 1

    Requirements. Functional: a list of conversations with unread counts, a message view, send text, see delivery states (sending, sent, delivered, read, failed), load older messages when scrolling up, see who is online and typing, keep working with a flaky connection. Non-functional: a sent message appears instantly (optimistic), messages arrive within about 500 ms, conversations with 100,000+ messages stay smooth, nothing is lost or duplicated across reconnects, keyboard and screen-reader friendly (new messages announced politely), and the app uses little battery on phones. Clarify: one-to-one only or groups (how big)? Attachments? End-to-end encryption? History searchable?

  2. 2

    WebSockets. A WebSocket is one long-lived, two-way connection between the browser and the server: either side can send a message at any time. Chat needs that, because the server must push new messages the moment they arrive. Alternatives: Server-Sent Events (server to client only; sending then uses normal HTTP requests) and long polling (a request the server holds open until there's news). The client wraps the socket in one module that reconnects with exponential backoff and jitter, sends heartbeats (small "are you there?" pings) to notice dead connections, and on reconnect asks for everything after the last sequence number it saw (Phase 24.6; the server side, with many socket servers behind a load balancer, is in /system-design).

  3. 3

    Ordering and ids. Every message gets two ids. The client id (clientId, for example from crypto.randomUUID()) is made by the sender before sending; it lets the client match the server's reply to its optimistic copy and lets the server ignore a duplicate when the same message is retried (an idempotency key). The server id and a per-conversation sequence number (seq: 1, 2, 3…) are assigned by the server, which is the one place that sees every message. Sort by seq, never by arrival order and never by the sender's clock (phone clocks are wrong surprisingly often). A gap (you have 41 and 43) means a missed message: fetch it.

  4. 4

    Delivery states and optimistic send. When the user presses Send, the message appears at once with state sending (a clock icon) and only a clientId. When the server acknowledges (an ack), it becomes sent (✓) and gets its seq. Delivered (✓✓) means the recipient's device received it, read means they opened it (read receipts). If the send fails or times out, it becomes failed with a Retry button; retrying sends the **same clientId**, so if the first attempt actually reached the server, the server returns the existing message instead of creating a second one. Pending messages stay at the bottom in the order they were sent until they get a seq.

  5. 5

    Scroll anchoring and unread counts. Three rules keep scrolling natural. (1) When the reader is at the bottom and a new message arrives, stay at the bottom. (2) When they've scrolled up to read history, don't move them: show a "3 new messages ↓" button instead. (3) When older messages load above, the content grows at the top, so the code must add the added height to scrollTop before the browser paints (useLayoutEffect, Phase 8), or the view jumps. Unread counts are per conversation: the server stores each member's lastReadSeq and the count is lastSeq - lastReadSeq; the client sends "read up to seq N" when messages are actually visible (debounced), and updates counts from socket events for conversations that aren't open.

  6. 6

    Offline queue and presence. When the connection drops, sends go into an outbox: an ordered queue, saved to IndexedDB so it survives a refresh, flushed in order when the socket reconnects (each item keeps its clientId, so a flush after a partial success is safe). The UI shows "Waiting for network" rather than failing. Presence (online, away, last seen) comes from heartbeats on the server; typing indicators are sent at most every few seconds while typing (throttled) and disappear after about 5 seconds without an update. Both are ephemeral: they're never stored as messages, and losing one is harmless.

Explain it without notes

01

How do you order chat messages correctly?

02

Walk through what happens when a user sends a message on a flaky connection.

03

How would you handle scrolling in a chat view?

04

How do unread counts and read receipts work?

05

What happens when the WebSocket disconnects and reconnects?

Practice

01

Show a delivery tick for delivered (✓✓) when a fake receipt event arrives for a message.

02

Add a typing indicator: when a fake typing event arrives, show Ravi is typing… and hide it 2 seconds after the last event.

Trade-offs

  • ↔

    WebSockets give low latency in both directions but need sticky, long-lived connections and a reconnect strategy; SSE plus HTTP POST is simpler to scale behind ordinary HTTP infrastructure but is one-way for push.

  • ↔

    Optimistic sends feel instant but the UI must handle failure clearly; never silently drop a failed message.

  • ↔

    Keeping all history in memory is simple but grows without limit; windowing the DOM and the store keeps big conversations fast at the cost of refetching when scrolling far back.

Done when you can

  • I can write requirements and draw the architecture of a chat client.

  • I can explain client ids, server sequence numbers and why to order by seq.

  • I can build optimistic send with delivery states and an idempotent retry.

  • I can keep scroll anchored when loading history and when messages arrive.

  • I can design unread counts, read receipts, an offline outbox and presence.