cf.completefrontend
My progress
SYSTEM DESIGN · #73

Design a chat app

The web client of a chat app with one-to-one and group chats, receipts, presence, typing, media, and offline sending. The hard parts are a live two-way connection that dies silently, sends that show at once but are never lost or doubled, one shared order across devices, years of history you can open anywhere, and recovering cleanly after sleep, network changes, and deploys.

The question

Design the web client of a chat app, like WhatsApp Web or Messenger. People have one-to-one chats and group chats of up to 500 members. They send text, photos, and files, see when their messages are delivered and read, see who is online and who is typing, and get notified about new messages.

Messages should reach the other person in well under a second, and nothing anyone sends may be lost or shown twice, even on train Wi-Fi, after the laptop sleeps, or with the app open in three tabs. Walk me through how you'd build the client: how it talks to the server, what it stores, and how it stays correct.

Real-life examples: WhatsApp Web, Facebook Messenger on the web, Slack direct messages, Telegram Web, Google Chat

Requirements: the questions that shape a chat client

"Build a chat app" sounds like a list of messages and a text box. In an interview it hides at least eight problems: a live connection, a message protocol, sending without losing or doubling, ordering, receipts, history, reconnecting, and several tabs and devices per person. You can't go deep on all eight in 45 minutes. The first five minutes decide which ones matter, and you decide that by asking questions whose answers change the design.

Ask about size first (how big are groups, how much history), then about people (how many devices, what networks), then about features (media, receipts, presence, notifications), and finally about the hard constraints (offline, encryption). The table gives the answer this guide assumes for each question, and what moves if the interviewer answers differently.

QuestionAnswer we assumeWhat it changes
One-to-one only, or groups too? How big?Both. Groups up to 500 members. A user has 50 to 300 chats.Receipts become counts in groups. Presence and typing need limits on fan-out.
Which devices and networks?Desktop browsers first, like WhatsApp Web, plus mobile web. Laptops sleep and wake many times a day.Reconnecting is an everyday path, not an edge case.
Several tabs and devices at once?Yes. A phone plus two browser tabs is normal.Read state and sends must reach every device. Tabs may share one connection.
How much history?Years. A busy group gets 2,000 messages a day.Pages in both directions, jumping to a message, a virtualized list.
What can a message hold?Text up to 4,000 characters, links, photos, and files up to 100 MB.An upload step before the send, and placeholders that don't shift the list.
What happens offline?Recent chats open from local data. Messages typed offline send on reconnect, even after a reload.An outbox and a local copy in IndexedDB.
Receipts, presence, typing?Yes, with settings to hide read receipts and last seen.Watermarks, and short-lived events that are never stored.
Notifications?Yes, when the tab is hidden or closed.The Notification API, and Web Push through a service worker.
Encryption?TLS now. End-to-end encryption as a follow-up.End-to-end moves search, previews, and key handling into the client.
A wireframe of the chat app in a browser window. On the left, the conversation list shows Team chat with 5 unread, Asha with 2, Bob, and Lena, newest activity first. On the right, the open chat with Asha has a header saying Asha, online. Asha's message lunch at 1? is followed by my reply yes, see you, marked read, and my pending message on my way, marked sending. Asha is typing. The composer is at the bottom. Notes say: presence and typing are never stored; ticks come from watermarks; a pending message has a clientId and sits in the outbox.chat.example.comTeam chat 5Asha 2BobLenanewest firstAsha, onlinelunch at 1?yes, see youreadon my waysendingAsha is typing...Type a messagepresence, typing:never storedticks come fromwatermarkspending: clientId,safe in the outbox
Every part of the screen maps to a decision: the list's order and counts, the header's ephemeral presence, ticks derived from watermarks, and a pending bubble that is safe in the outbox before the server answers.

Functional requirements

  • Send and receive text messages in one-to-one and group chats in real time.
  • Show each of my messages as sending, sent, delivered, read, or failed with a Retry button.
  • Open a chat at the first unread message, load older and newer history as the user scrolls, and jump to any message from a search result or a quoted reply.
  • Show a conversation list ordered by latest activity, with unread counts, mentions, and a preview of the last message.
  • Show who is online or when they were last seen, and who is typing.
  • Send photos and files with upload progress, and show them without the list jumping.
  • Keep working offline: read cached chats, write messages that go out when the connection returns.
  • Notify the user about new messages when the tab is hidden or the app is closed.

Non-functional requirements, with numbers

Non-functional requirements are qualities, not features: how fast, how reliable, how accessible. Give each one a number, because "fast" can't be checked and "p95 under 500 ms" can. The p95 is the 95th percentile: 95 out of 100 messages must meet it.

  • Instant send. My bubble appears in the same frame as the Enter key, and every interaction responds within 200 ms (INP, Interaction to Next Paint).
  • Fast delivery. A message reaches the other person's open devices within 500 ms at p95 on the same continent.
  • Never lost, never doubled. Zero lost or duplicated messages across dropped connections, retries, reloads, and closed tabs.
  • One order. Everyone in a chat sees the messages in the same order, whatever their device clocks say.
  • Quick recovery. After a laptop wakes, the app is current within 2 s on a normal network. After a server deploy drops a million connections at once, clients come back without knocking the servers over.
  • Fast open. The conversation list and the last open chat show from local data within 300 ms of page load. A chat scrolled back through 10,000 messages still scrolls at 60 frames per second.
  • Accessible. Screen reader users hear new messages in the open chat without losing their place, and everything works from the keyboard.
  • Light when idle. An idle connection costs under 1 KB per minute, so a tab left open all day doesn't drain a laptop battery.

Out of scope, said out loud

  • Voice and video calls. They need WebRTC and media servers: a different problem.
  • How the servers store and fan out messages. We state what the client needs from them (a sequence number per chat, an event log per user) and stop there.
  • Full-text search across all history. It needs a server-side index. We cover jumping to a result.
  • Broadcast channels with 100,000 members. They behave like a feed, not a chat: no receipts, no typing, no presence list.

Check your understanding · Exercise 1

Which answers from the interviewer would change the client design the most? Select all that apply.

What makes chat hard

Before choosing anything, name the forces. Every later decision in this guide comes from one of these eight properties. If you can list them in one breath, you can rebuild most of the design live, instead of reciting it.

  1. It's two-way and live. Either side can speak at any moment, and the other side should hear it within half a second. Request and response, where only the browser starts a conversation, doesn't fit.
  2. Order is shared. Everyone in a group must see the same order. Device clocks can be minutes off, so the browser can't decide order by itself.
  3. The network fails all the time. Laptops sleep, phones switch from Wi-Fi to 4G, proxies cut idle connections after 60 s, and every server deploy drops millions of sockets. Often nothing tells the browser.
  4. People expect exactly once. A lost message breaks trust. A message shown twice is embarrassing, and in a group of 500 it's 500 people's problem.
  5. One person is many clients. A phone, a laptop, and two tabs all belong to the same user. Read state, drafts, and sends must agree across them.
  6. History is long and you enter it anywhere. Years of messages, opened at the first unread one, at a search result, or at a quoted reply, then scrolled in both directions.
  7. Most traffic is tiny signals. Typing, presence, and receipts outnumber messages by about ten to one, and they are worthless a few seconds late.
  8. It runs all day in a background tab. Timers get throttled, memory must stay flat for hours, and the user must still be told when something arrives.

Put a scenario on the third one, because it drives the most design. Asha writes "running 10 min late" on a train. Her message makes six hops, from her composer to Bob's tabs, and any hop can fail.

If the train enters a tunnel after the server stored the message but before its confirmation came back, Asha's browser doesn't know whether the message exists. Everything clever in a chat client is about answering that question without losing or doubling anything.

One message's journey. Asha's composer hands it to the outbox in IndexedDB, then to the socket over WSS, then to the gateway. The gateway writes it to the message log, which assigns a seq, and fans it out to Bob's two tabs and phone. The tab can close before the send, the laptop can sleep on the socket, the ack can be lost on the way back from the gateway, a deploy can drop the fan-out, and Bob can be offline.composerAsha typesoutboxIndexedDBsocketWSSgatewayauth, routingmessage logassigns seqfan-outevery deviceBob2 tabs, phonetab closed?laptop sleeps?ack lost?deploy drops it?Bob offline?each hopcan fail
Six hops, and any of them can fail. The hardest case is a lost ack: the server has the message, but the sender can't know it.

The chapters follow the forces: transport and protocol first, then sending, ordering, and receipts, then history, reconnecting, and tabs, then the smaller features, and finally the architecture and the polish.

Think first

A teammate says: "Open a WebSocket, send the text, and append whatever comes back to an array." What breaks first, and when?

The live connection: choosing a transport

Start with the naive version, because it's what many people draw first: every 2 seconds, the browser asks GET /messages?since=.... This is short polling. It works with any server and any proxy.

Now put numbers on it. With 5 million users online, that's 2.5 million requests per second, and more than 99% of them come back empty. Each one carries about 800 bytes of headers and cookies, so the servers read 2 GB per second of headers to say "nothing new". And a message waits 1 s on average before anyone asks for it.

Chat also talks upward constantly: typing pings, read receipts, presence. With polling, each of those is a separate full HTTP request. So the question is not only "how does the server push to me" but "what's the cheapest way for both sides to send small messages at any moment".

Four transports compared on four things. Short polling: about 1 second average delay, every upward event is a request, works through all proxies, resume is by hand. Long polling: instant, every upward event is a request, works through nearly all proxies, resume by hand. Server-Sent Events plus POST: instant, every upward event is a request, nearly all proxies, resume built in with Last-Event-ID. WebSocket: instant, about 30 bytes per upward event, works through most proxies, resume by hand.delayupward costproxiesresumeshort poll~1 s avga requestalwaysby handlong pollinstanta requestnearly allby handSSE + POSTinstanta requestnearly allbuilt inWebSocketinstant~30 bytesmostby handupward cost: one typing ping or receipt
Only WebSocket makes a typing ping or a receipt cheap, and chat sends those all the time. SSE wins on resume because EventSource sends Last-Event-ID by itself.

Decision How should the browser and the server exchange live messages in both directions?

Short polling

The client asks for news every few seconds with a normal GET, and sends with a normal POST.

Pros

  • Works everywhere, through every proxy and firewall.
  • Every request is stateless, so servers are simple to scale.

Cons

  • Average delay is half the interval: 1 s at a 2 s interval.
  • Millions of empty requests per second, each with full headers.
  • Every typing ping and receipt is its own request.

Long polling

The client asks for news, and the server holds the request open until something happens or 30 s pass. The client asks again at once. Sends are POSTs.

Pros

  • Delivery is near instant, with plain HTTP.
  • Works through almost every proxy, so it's a good fallback.

Cons

  • A full request cycle per delivery, and a gap while the next request is set up.
  • Ordering across two overlapping requests needs care.
  • Upward traffic is still one request per event.

Server-Sent Events plus POST

One long-lived EventSource stream carries everything from the server. The browser sends with POST or fetch.

Pros

  • Plain HTTP: works with HTTP/2 and HTTP/3, and with normal auth and dev tools.
  • Built-in reconnect, and Last-Event-ID tells the server where to resume.

Cons

  • One direction only. Every typing ping and receipt is a full request with headers and cookies.
  • On HTTP/1.1 each stream uses one of only 6 connections the browser allows per domain.

WebSocket

One connection, upgraded from HTTP, that both sides can write to at any time. Each message is a frame with 2 to 14 bytes of overhead.

Pros

  • Lowest latency both ways, and the cheapest small upward messages.
  • One ordered stream per client, which makes acks and resume simple to reason about.

Cons

  • Stateful: servers must hold millions of connections, and every deploy drops them.
  • No built-in reconnect, resume, heartbeat, or auth header. You build them.
  • A few corporate proxies still break it.

What we pick: WebSocket, because chat sends small messages in both directions all the time, and that's exactly where it beats the others: a typing ping costs about 30 bytes instead of 800. Keep long polling behind the same client interface as a fallback for networks that block WebSockets. Server-Sent Events plus POST is a perfectly good second choice, especially over HTTP/2, if the team prefers plain HTTP. Mention it as a real alternative, not a wrong answer.

Opening the socket

The browser's WebSocket constructor can't set an Authorization header. Cookies do go along with the upgrade request, but then any other site can open a socket to yours with the user's cookies (cross-site WebSocket hijacking), so the server must check the Origin header.

The common pattern is a ticket: the client calls POST /realtime/ticket with its normal auth and gets a random, single-use token valid for 30 seconds. It opens wss://rt.example.com/v1?ticket=... and the gateway checks the ticket once. A leaked ticket in a log is useless a minute later.

Put a protocol version in the URL (/v1). A tab can stay open for a week, so it will talk to servers deployed long after it loaded. When the server can't speak to an old client any more, it closes with a code that tells the client to reload.

Heartbeats: is the line still alive?

Connections die silently. A laptop sleeps for an hour. A phone moves from Wi-Fi to 4G and gets a new IP address. A load balancer drops a connection that was idle for 60 seconds (the default idle timeout on many cloud load balancers).

In each case, socket.readyState can still say OPEN, because TCP only notices a dead peer when it tries to send something and fails. The app looks connected and silently receives nothing.

The WebSocket protocol has ping and pong frames, but JavaScript in the browser can't send them or see them. So add an app-level heartbeat: the client sends {"type":"ping"} every 25 seconds, and if no frame of any kind arrives within 10 seconds after that, it closes the socket and reconnects. The server does the same in reverse and drops connections that are silent for 60 seconds, so dead sockets don't pile up on it.

ts
function startHeartbeat(socket: WebSocket, onDead: () => void) {
  let deadline: number | undefined;
  const check = () => {
    if (socket.readyState !== WebSocket.OPEN) return;
    socket.send(JSON.stringify({ type: "ping" }));
    deadline ??= window.setTimeout(onDead, 10_000);
  };
  const alive = () => {
    window.clearTimeout(deadline); // any frame proves the line works
    deadline = undefined;
  };
  const timer = window.setInterval(check, 25_000);
  socket.addEventListener("message", alive);
  // Check at once when the tab comes back or the network changes.
  document.addEventListener("visibilitychange", check);
  window.addEventListener("online", check);
  return () => {
    window.clearInterval(timer);
    alive();
    socket.removeEventListener("message", alive);
    document.removeEventListener("visibilitychange", check);
    window.removeEventListener("online", check);
  };
}
Any incoming frame counts as proof of life, so a busy chat never needs a pong. The extra checks on visibilitychange and online find a dead line in about 10 s instead of up to 35 s.
A heartbeat as a sequence diagram between your tab and the gateway. Your tab sends ping and gets pong. Then the laptop sleeps for an hour and the gateway drops the connection, but the tab still thinks it is open. On wake, the tab sends ping right away and gets no answer. After 10 seconds of silence it closes the socket, opens a new one, and sends hello with its lastEventSeq.Your tabGatewayping (every 25 s)ponglaptop sleeps 1 h,gateway drops itping on wake: no answer10 s of silence: closenew socket, hello lastEventSeq
Without the ping on wake, the tab would show "connected" and receive nothing. Any frame counts as an answer, so a busy chat never needs a separate pong.

Two details matter in practice. First, browsers throttle timers in background tabs, sometimes to once a minute after a few minutes hidden, so the server's timeout must be generous and the client must re-check when the tab becomes visible. Second, the offline event is a hint, not a verdict: it fires when the device has no network at all, but online doesn't mean the internet is reachable. Use both events only as reasons to check sooner.

Finally, watch socket.bufferedAmount, the bytes queued but not yet sent. If it keeps growing, drop typing pings and presence first, since they're worthless late, and let messages through.

Check your understanding · Exercise 2

Which statements about the WebSocket connection are true? Select all that apply.

The message protocol: envelopes, ids, and sequence numbers

A WebSocket gives you two things: send() and a message event. Everything else is a protocol you design. The naive protocol is no protocol: socket.send("running late"), and the server forwards the text to the other members. It breaks the moment you add a second feature.

Is this frame a message or a typing ping? You sent three messages quickly and one confirmation came back: which one? Two people sent at the same second: who is first? You were offline for ten minutes: what did you miss?

Each of those questions is answered by one field in the frame. So design the frame first. Every frame is a small JSON object called an envelope: a type that names what it is, plus the fields that type needs. The client switches on type, and unknown types are ignored, so the server can add new events without breaking old tabs.

json
// client to server
{ "type": "message.send", "clientId": "0f6c2a9e-41d7-4c1f-9b55-3c2f8a1e7d20",
  "conversationId": "c_88", "body": "running 10 min late" }

// server to the sender: the confirmation
{ "type": "ack", "clientId": "0f6c2a9e-41d7-4c1f-9b55-3c2f8a1e7d20",
  "id": "m_51023", "seq": 1045, "createdAt": "2026-10-18T08:12:03.120Z" }

// server to every device of every member, including all of the sender's tabs
{ "type": "message.new", "eventSeq": 88121,
  "message": { "id": "m_51023", "seq": 1045, "clientId": "0f6c2a9e-41d7-4c1f-9b55-3c2f8a1e7d20",
    "conversationId": "c_88", "senderId": "u_asha", "body": "running 10 min late",
    "createdAt": "2026-10-18T08:12:03.120Z" } }
One send, three frames. The same clientId ties all three together, wherever and in whatever order they arrive.
The fields of a message.new frame and their jobs. type, message.new, says what kind of frame it is. eventSeq 88121 answers what have I missed. clientId answers is this my send. seq 1045 answers where does it go. id m_51023 is what replies point at. version 1 answers whether an edit is newer.type: message.newwhat kind of frameeventSeq: 88121what have I missed?clientId: 0f6c...is this my send?seq: 1045where does it go?id: m_51023what replies point atversion: 1is this edit newer?
Three of the fields carry the whole design: clientId for sends, seq for order, eventSeq for catching up.

Three ids, three jobs

  • clientId: made by the sender's browser with crypto.randomUUID() before anything is sent. It names one send attempt. The ack carries it back, so the client knows which pending bubble was confirmed. It's also the idempotency key: a key that lets the server recognize a repeated request and not act twice.
  • seq: a counter the server assigns per conversation when it stores a message: 1, 2, 3, with no gaps. It's the one true order of the chat. Sorting, history cursors, gap detection, and read receipts all use it.
  • eventSeq: a counter per user, across all their chats. Every event the server pushes to this user (new message, receipt, edit, member added) gets the next number. After a disconnect, the client says "I have everything up to 88121" and the server replays the rest.

The message also gets a global id from the server, which other features use to point at it (replies, reactions, links). Keep the three numbers apart in your head: clientId answers "is this my send?", seq answers "where does it go?", and eventSeq answers "what have I missed?".

Decision What decides the order of messages in a chat?

The sender's clock

Each client stamps createdAt = Date.now() and everyone sorts by it.

Pros

  • No server work, and the order is known before the server answers.

Cons

  • Device clocks drift by seconds or minutes, and some are simply wrong. A message from a phone set 5 minutes fast lands above replies to it.
  • Two devices can produce the same millisecond, so ties need a second rule.

The server's clock

The server stamps createdAt when it stores the message, and clients sort by it.

Pros

  • One clock instead of millions.
  • A natural cursor for history pages.

Cons

  • Large backends store messages on many machines with slightly different clocks.
  • Ties within a millisecond still need a tie-breaker.
  • A gap in time says nothing about a missing message.

A server sequence number per conversation

The server that owns a conversation gives each stored message the next integer: 1044, 1045, 1046. Clients sort by it.

Pros

  • Exact and tie-free: everyone sees the same order.
  • Gapless, so a jump from 1041 to 1044 proves two messages are missing.
  • A perfect cursor for history and a compact read watermark.

Cons

  • Each conversation needs a single writer for its counter, which the backend must provide.
  • A message has no position until the server answers, so pending messages need their own rule.

What we pick: A server sequence number per conversation. It turns order, gaps, history cursors, and receipts into integer comparisons. A per-chat counter scales because chats are independent: no global counter is needed. Timestamps stay in the message for display ("08:12"), never for sorting. Pending messages sort after every confirmed one, in the order the user sent them.

Acks and errors

Every frame that changes data gets exactly one answer carrying the same clientId: an ack with the server's ids, or an error with a code. The code tells the client whether to try again, so make that explicit instead of guessing from the text.

Error codeMeaningWhat the client does
rate_limitedToo many sends; carries retryAfter in seconds.Pause that chat's queue, then resend with the same clientId.
unavailableThe server couldn't store it right now.Resend with backoff. The same clientId makes it safe.
forbiddenYou're not a member any more, or you're blocked.Mark the message failed. No retry.
too_largeThe body is over the 4,000 character limit.Mark failed and tell the user. The composer should have caught it.
version_unsupportedThe tab's protocol version is too old.Close the socket and ask the user to reload.

Check your understanding · Exercise 3

Which field should the client use for each job? Select all that apply.

Sending: instant, retried, and never doubled

The first naive version waits: press Enter, send, and show the message when the server confirms it. On a laptop on fiber that's 50 ms and nobody notices. On a train with a 1.5 s round trip, the text box clears and nothing appears for a second and a half, and offline it never appears at all. Users press Enter again, and now there are two messages.

So the bubble must appear at once: an optimistic update, which shows the expected result before the server confirms it. That moves the hard part to the failure case.

Say the socket dies right after the frame left. Maybe the server stored the message, maybe it didn't. The client can't know: the only proof would be the ack, and the ack is exactly what went missing. It has two choices, resend or don't, and each one is wrong half the time unless the server helps.

Decision When a send's ack never arrives, what should the client do?

Never resend

Mark the message failed and let the user decide. This is called at-most-once delivery.

Pros

  • Can never create a duplicate.

Cons

  • Every tunnel on a train produces red failed messages, and many of them were actually delivered.
  • The user resends by hand, which creates exactly the duplicates we wanted to avoid.

Resend blindly

Keep the message in a queue and send it again after reconnecting, as a new message. This is at-least-once delivery.

Pros

  • Nothing is ever lost.

Cons

  • Every lost ack becomes a duplicate for everyone in the chat.
  • In a group of 500, one flaky train ride is visible to 500 people.

Resend with an idempotency key

Every resend carries the same clientId. The server keeps a unique index on sender plus clientId. If it has seen the pair, it stores nothing and returns the original ack, with the original seq.

Pros

  • Nothing lost and nothing doubled: effectively exactly once, as the user sees it.
  • Retries become safe by default, so the client can retry freely.

Cons

  • The server must keep the keys at least as long as clients may retry, for example 7 days.
  • Every write type (send, edit, react) needs its own key.

What we pick: Resend with an idempotency key. Networks can't give exactly-once delivery, but at-least-once delivery plus a server that ignores repeats looks exactly-once to the user. It costs one unique index on the server, and it's the single most important idea in a chat client's design.

A send as a sequence diagram between your tab, the gateway, and Bob's tab. Your tab sends message.send with client id c7. The gateway pushes message.new with seq 1042 to Bob. The ack for c7 with seq 1042 is lost on the way back. After reconnecting, your tab resends c7. The server finds c7, stores no copy, and returns the same ack with seq 1042.Your tabGatewayBob's tabmessage.send c7message.new seq 1042ack c7: lostresend c7 after reconnectsame ack: seq 1042server finds c7,stores no copy
The bubble shows at once with seq: null. Because the resend carries the same clientId, a lost ack costs one extra frame, not a duplicate message in Bob's chat.

A send, step by step

  1. The user presses Enter. The composer makes a clientId and clears the text box.
  2. The store adds the message with status: "sending" and seq: null. React renders it in the same frame, with a clock icon.
  3. The outbox, a store in IndexedDB, saves the message. From now on, closing the tab can't lose it.
  4. If the socket is open, the sync engine sends message.send. If not, the message waits as queued and the clock icon stays.
  5. The ack arrives. The store fills in id, seq, and the server's createdAt, drops the client-only status (the tick now shows sent), and moves the bubble to its seq position. The outbox entry is deleted.
  6. Later, receipts from the other members move it to delivered and read (next chapters).
The states of one of my messages. Queued, while offline, becomes sending when the socket is open. Sending becomes sent when the ack arrives. Sent becomes delivered when the other member's deliveredSeq reaches it, and read when their readSeq does. Sending becomes failed on forbidden, too large, or after 24 hours, and Retry sends it again with the same clientId. Sent, delivered, and read are derived, not stored.queuedsendingsentdeliveredreadonlineackdeliveredSeqreadSeqforbidden, too_large, or 24 hfailedRetry: same clientIdsent, delivered, read:derived, not stored
Only the first two states belong to the client. Everything after sent is a comparison with the other member's watermarks.

When to retry, and when to give up

On an open WebSocket, frames don't get lost one by one: the connection either delivers them in order or dies. So don't resend on a timer while the socket is up. If no ack arrives within 10 s, treat the connection as suspect and run the heartbeat check.

Resending happens on reconnect, when the sync engine flushes the outbox in the order the messages were created. Retryable errors (rate_limited, unavailable) are resent with backoff.

Keep each chat's sends in order. Pipelining them on one socket is fine, because the server reads one connection's frames in order. But if one send gets a retryable error, hold every later send of that chat until it succeeds. Otherwise "Are you free tonight?" can land after "Great, see you then".

Mark a message failed, in red with Retry and Delete buttons, only for permanent errors (forbidden, too_large) or after 24 hours of trying. Retry reuses the same clientId, because it's the same message. Delete removes it from the outbox; if a send is in flight, the client waits for the ack and then sends a delete, since the server may already have it.

Check your understanding · Exercise 4

Asha sends a message, the train enters a tunnel, and the ack is lost. What does a correct client do? Select all that apply.

Ordering and deduplication: one list from many sources

A message can reach the client's store by six different paths: my own optimistic insert, my ack, a live message.new push (which the sender's own tabs get too), a replay after reconnecting, a history page over HTTP, and the IndexedDB cache when the app starts. These paths overlap, and they arrive in any order. Bob's tab may get the push before your tab gets your ack. A history page may contain a message that also arrived live a second earlier.

The naive store appends whatever arrives to an array. That shows the same message twice the first time two paths overlap, and puts messages in arrival order instead of chat order. The fix is one rule, used by every path: upsert by key (insert if new, update if known) and derive the order from seq, never from arrival.

SourceWhat it carriesHow it merges
Optimistic insertclientId, body, no seqNew pending entry keyed by clientId.
AckclientId, id, seqFinds the pending entry by clientId, fills in ids, keeps the same key.
Live message.newFull message with seq; clientId if it's one of mineMatches a pending entry by clientId first, then by id; otherwise inserts.
Replay after reconnectSame as liveSame rule. Events already applied are skipped by eventSeq.
History page50 full messagesUpsert each by id, and record the loaded range.
IndexedDB on startupRecent messages and the outboxLoaded first; everything later merges over it.
Six sources feed one upsert: the optimistic insert, the ack, the message.new push, the replay after reconnecting, a history page, and the IndexedDB cache. The upsert matches by clientId, then id, and writes confirmed messages sorted by seq and pending messages in send order.optimistic insertackmessage.new pushreplayhistory pageIndexedDB cacheupsertby clientId, idconfirmedsorted by seqpendingin send orderarrival order never matters
Every path calls the same function, so it doesn't matter which copy arrives first or how many copies arrive.
ts
function upsertMessage(chat: ChatState, incoming: Message) {
  // A server copy of my own pending send: confirm it in place.
  const pending = incoming.clientId ? chat.pending.get(incoming.clientId) : undefined;
  if (pending) chat.pending.delete(incoming.clientId!);

  const known = chat.bySeq.get(incoming.seq);
  if (known && known.version >= incoming.version) return; // duplicate or older edit

  chat.bySeq.set(incoming.seq, { ...incoming, key: pending?.key ?? known?.key ?? incoming.id });
  if (incoming.seq > chat.highestSeq + 1 && chat.highestSeq > 0) {
    chat.gaps.push([chat.highestSeq + 1, incoming.seq - 1]); // fetch with ?after=
  }
  chat.highestSeq = Math.max(chat.highestSeq, incoming.seq);
}

// What the list renders: confirmed by seq, then pending in send order.
const rows = (chat: ChatState) => [
  ...[...chat.bySeq.values()].sort((a, b) => a.seq - b.seq),
  ...[...chat.pending.values()].sort((a, b) => a.localOrder - b.localOrder),
];
Every path calls the same function, so the order of arrival stops mattering. In a real store, keep the confirmed list sorted on insert instead of sorting on every render.

Where a pending message sits

A pending message has no seq, so it sorts after every confirmed message. Usually its seq turns out to be the next one, and nothing moves.

But say you send "ok" while Bob's "see you at 8" is on its way. The server stores Bob's first (1045) and yours second (1046), so nothing moves either.

If yours was stored first (1045) but Bob's push arrived before your ack, your pending bubble sits below Bob's for a moment, then moves above it when the ack says 1045. That small jump is correct: it's the order every other member sees. Animating the move over 150 ms makes it readable instead of startling.

Gaps, edits, and deletes

Because seq is gapless, a jump is proof of a missed message. If live traffic brings 1041 and then 1044, the client fetches ?after=1041 for that chat and fills the hole, while still showing 1044 in place. Deletes must not create gaps, so a deleted message stays as a tombstone: a record with the same seq that renders as "This message was deleted".

Edits and deletes are events too, carrying the message id and a version that goes up with each change. The upsert ignores a copy whose version isn't newer than what it has. That one comparison makes the order of arrival irrelevant for edits as well: a history page fetched before an edit can't overwrite the edited text that arrived live.

Check your understanding · Exercise 5

The message.new push for your own message reaches this tab before its ack does. What should the tab do with the push? Select all that apply.

Delivery and read receipts: watermarks, not flags

The ticks under a message tell its sender three things: the server has it (sent), a device of the recipient has it (delivered), and the recipient has seen it (read). The naive model stores a flag per message per member: "Bob read message m_51023". The client sends a read frame for every message that scrolls into view.

Count what that costs. Bob opens a chat with 300 unread messages and scrolls down: 300 frames up, 300 rows written, 300 events pushed to Asha. In a group of 500 members that gets 2,000 messages a day, the server writes a million read rows a day for one group, and every member's scroll fans out to every sender. Most of that information is redundant, because people read chats in order: if Bob read message 1046, he has read 1045 too.

Decision How should the client and server represent who has received and read what?

A flag per message per member

Each member has a delivered and a read flag on each message. Clients report each message they see.

Pros

  • Exact, even if someone reads out of order.
  • Simple to explain.

Cons

  • One frame and one row per message per member: reading 300 messages costs 300 frames.
  • In big groups the rows and the fan-out grow as messages times members.

A watermark per member

For each member of a chat, keep two numbers: deliveredSeq, the highest seq any of their devices received, and readSeq, the highest they've seen. A message is read by Bob if its seq is at most Bob's readSeq.

Pros

  • Reading 300 messages is one frame: read 1046.
  • Storage is two numbers per member per chat, whatever the message count.
  • Every tick is derived, so one update fixes all earlier messages at once.

Cons

  • Can't say "read message 900 but not 899". Chat UIs never need to.
  • Needs the ordered seq from the protocol chapter.

Counts only

The server keeps a running "read by N" counter per message and never says who.

Pros

  • Tiny and cheap for very large groups.

Cons

  • Can't show ticks in a one-to-one chat or the "Read by" list in a group.
  • Still one update per message per reader.

What we pick: Watermarks per member, for one-to-one chats and groups alike. They match how people read (in order), cost one frame per scroll instead of one per message, and every tick becomes a comparison. In groups, "Read by 37" is just the number of members whose readSeq is at least the message's seq.

Message states and watermarks. One message goes from sending to sent, which comes from my ack, then to delivered and read, which come from Bob's watermarks. Sending can also become failed on a permanent error or after 24 hours. Below, messages seq 40 to 47 in one chat: Bob's readSeq is 44, so 40 to 44 are read; his deliveredSeq is 46, so 45 and 46 are delivered; 47 is only sent.one messagesendingsentdeliveredreadfrom my ackfrom Bob's watermarksfailedpermanent error or 24 hBob's watermarks in this chat4041424344454647readSeq = 44deliveredSeq = 46sent
Two numbers per member decide every tick. Bob reading up to 44 is one frame, read 44, no matter how many messages that covers.
ts
type Tick = "sending" | "failed" | "sent" | "delivered" | "read";

// My message in a one-to-one chat. Nothing is stored: the tick is derived.
function tickFor(message: Message, other: MemberReceipt): Tick {
  if (message.seq === null) return message.status === "failed" ? "failed" : "sending";
  if (other.readSeq >= message.seq) return "read";
  if (other.deliveredSeq >= message.seq) return "delivered";
  return "sent";
}

// Receipts can arrive late or twice. Watermarks only move forward.
function applyReceipt(r: MemberReceipt, update: MemberReceipt) {
  r.deliveredSeq = Math.max(r.deliveredSeq, update.deliveredSeq, update.readSeq);
  r.readSeq = Math.max(r.readSeq, update.readSeq);
}
Math.max makes a late or repeated receipt harmless: a slow phone reporting readSeq: 41 can't undo a laptop's 44. Read also implies delivered.

When is a message really read?

Delivered is easy: when any device stores a message, it reports the highest seq it has received, batched to at most one frame per second.

Read is where clients get it wrong. A chat open in a background tab has not been read. Neither has a message far below the visible part of the thread. Send read only when all three are true: the page is visible (document.visibilityState === "visible"), the window has focus (document.hasFocus()), and the message is at least half on screen, which an IntersectionObserver reports without any scroll listeners.

Then send the highest visible seq, debounced by about 500 ms so a fast scroll sends one frame, not twenty. Two more rules: sending a message moves my own readSeq up to it (I've obviously read everything above my own reply), and a read frame is idempotent, so it's safe to send again after a reconnect.

Groups and privacy

In a 500-member group, pushing every member's watermark change to every other member would be 250,000 events for one round of reading. Nobody needs that. The server sends receipt updates only to the authors of the affected messages, and only for their last few messages, so the sender sees "Read by 37" tick up. The full list of who read what loads on demand, when someone opens a message's info panel.

If Bob turns read receipts off, the server still stores his readSeq (his own devices need it for unread counts) but stops sharing it with others. Most apps make it mutual: Bob then doesn't see other people's read ticks either. That rule lives on the server, and the client just renders what it gets.

Think first

Bob reads a chat on his phone. Why does the unread badge on his laptop clear too, and what would break if read state were kept only on each device?

Check your understanding · Exercise 6

Which rules for sending read receipts are correct? Select all that apply.

History: pages in both directions, and jumping to a message

A busy group has 50,000 messages, about 15 MB of JSON. Loading all of it is out of the question, so history comes in pages. The naive page is ?offset=50&limit=50, counted from the newest message. But the newest message keeps changing: if 5 messages arrive while you read, "skip 50" now skips those 5 plus only 45 of the old ones, and the next page repeats 5 messages you've already seen.

Chat has a second twist that feeds don't: people don't always enter at the newest message. They open a chat at the first unread message, tap a search result from last March, or tap a quoted reply to jump to the original. From there they scroll up for older messages and down for newer ones. So the client needs pages in both directions, starting anywhere.

Decision How should the client fetch and keep history pages?

Offset pages from the newest

GET /conversations/c_88/messages?offset=100&limit=50: skip the newest 100, return the next 50.

Pros

  • Simple, and easy to cache by page number.

Cons

  • Every new message shifts every page, so pages repeat or skip.
  • Can't start in the middle: there's no way to say "around message 812".
  • Deep offsets are slow on the server.

Time cursors

?before=2026-03-02T10:00:00Z: return the 50 messages older than a timestamp.

Pros

  • Stable as new messages arrive.
  • Can start at a date, like "jump to March".

Cons

  • Ties at the same millisecond can skip or repeat a message at a page edge.
  • A time range says nothing about whether a message is missing inside it.

Sequence cursors with loaded ranges

Three forms of one endpoint: ?before=1201, ?after=1342, and ?around=812, each with limit=50. The client keeps a list of the seq ranges it has loaded per chat, like [1155, 1205] and [1300, 1342], and merges ranges when they touch.

Pros

  • Exact and stable: seq never moves and never ties.
  • Can start anywhere and grow in both directions.
  • Ranges show precisely what's loaded and what's a hole.

Cons

  • The client must track and merge ranges.
  • Two disconnected ranges need care in the UI.

What we pick: Sequence cursors with loaded ranges. The same seq that orders messages also names every page edge exactly, and around makes opening at the first unread or at a search result a single request. Tracking ranges is a few dozen lines, and it's what makes jumping around years of history correct.

A chat's history as seq ranges after jumping to message 812. Seq 1 to 789 is not loaded. Seq 790 to 840 is loaded around the jump target. Seq 841 to 1299 is a hole. Seq 1300 to 1342, the newest, is loaded. Scrolling up fetches before=790; scrolling down fetches after=840. Live messages join the newest range, and ranges merge when they touch.not loadedjumped herea holenewest1 to 789790 to 840841 to 12991300 to 1342?before=790?after=840live messages jointhe newest rangeranges mergewhen they touch
The client can hold islands of history. around creates one, before and after grow it, and touching ranges merge into one.

Opening a chat

  1. Show the newest 50 messages from IndexedDB at once, if they're cached, so the chat paints in under 100 ms.
  2. If the chat has unread messages, ask ?around= my readSeq + 1, so the first unread message is in the middle of the page. Otherwise ask ?after= the newest cached seq to catch up.
  3. Render the range that contains the target, scroll the first unread message into view with a "New messages" separator above it, and keep the separator until the chat is closed.
  4. When the user scrolls within about 1,000 px of either edge of the range, fetch the next page in that direction. One request per direction in flight, never more.

Jumping to a message

Tapping a quoted reply that points at seq 812 works the same way. If 812 is in a loaded range, scroll to it. If not, fetch ?around=812, which creates a new range, and switch the thread to that range.

Center the message, highlight it for 2 seconds, and move keyboard focus to it so screen reader users land in the right place too. A "Jump to latest" button now appears, because the user is no longer at the bottom of the chat.

While the user reads old history, new messages still arrive. They go into the newest range in the store, but the thread doesn't move: the "Jump to latest" button shows a count instead ("3 new"). Scrolling down through old history eventually fetches ?after= until the ranges touch and merge, at which point the user is simply at the bottom again.

Check your understanding · Exercise 7

A user taps a search result for a message from eight months ago. Which steps belong in the flow? Select all that apply.

Reconnecting and catching up

A chat client disconnects many times a day: the laptop lid closes, the train enters a tunnel, the Wi-Fi switches access points, the server deploys. Reconnecting well has three parts: notice the connection is gone, wait the right amount of time, and catch up on everything that happened meanwhile without losing or doubling anything.

Noticing is the heartbeat from the transport chapter, plus the socket's close event, plus three hints that say "check now": the online event, visibilitychange to visible, and in Chromium the Page Lifecycle resume event after a frozen tab is thawed. The interesting parts are the waiting and the catching up.

Waiting: backoff with jitter

The naive reconnect loop retries every second. Now picture a server deploy that drops 1 million connections at once. Every client retries one second later, at the same moment: 1 million TLS handshakes in one second, against servers that can accept maybe 100,000 per second.

Most fail, and they all retry together again. This is a reconnect storm, also called the thundering herd, and it can keep a fleet down long after the deploy finished.

Two fixes, used together. Exponential backoff: the wait limit doubles with each failed attempt, from 1 s up to a cap of 30 s, so failing clients ask less and less often. Jitter: each client waits a random time between zero and that limit, so the herd spreads into a steady stream that the servers can absorb. Reset the attempt counter only after a connection has stayed up for 10 seconds; otherwise a server that accepts and then crashes keeps every client in a tight loop.

ts
const BASE_MS = 1_000;
const CAP_MS = 30_000;
let attempt = 0;

// "Full jitter": a random wait between 0 and the exponential limit.
function reconnectDelay(n: number) {
  const limit = Math.min(CAP_MS, BASE_MS * 2 ** n);
  return Math.random() * limit;
}

function onClose(event: CloseEvent) {
  // 1012 is "service restart": start with a wider window so clients spread out.
  const n = event.code === 1012 ? Math.max(attempt, 3) : attempt;
  window.setTimeout(connect, reconnectDelay(n));
  attempt = n + 1;
}

// In connect(): reset only after the socket has stayed up for 10 s.
// stableTimer = window.setTimeout(() => { attempt = 0; }, 10_000);
The randomness matters more than the doubling. Without it, a million clients that back off by exactly the same amounts still arrive together, just less often.
Wait before retrying
Servers accept per second

Backoff without jitter, servers accept 100k handshakes per second. Peak: 1.0M in one second. all back after 210 s, with 4.5M failed handshakes.

Reconnect attempts per second for the first 24 seconds after a deploy drops 1 million connections. Backoff without jitter, servers accept 100k handshakes per second. Peak: 1.0M in one second. all back after 210 s, with 4.5M failed handshakes. Seconds with more attempts than the servers accept are marked as overloaded.1Mcap0 s4 s8 s12 s16 s20 speak: 1.0M in one secondall back after 210 sfailed handshakes: 4.5Mcapacity: 100k per second
Backoff without jitter, servers accept 100k handshakes per second. Peak: 1.0M in one second. all back after 210 s, with 4.5M failed handshakes.

Catching up: resume, then resync

Decision After reconnecting, how does the client learn what it missed?

Refetch everything

Reload the conversation list and the newest page of every chat, as if the app had just started.

Pros

  • Simple, and always correct in the end.

Cons

  • With 300 chats, that's hundreds of requests on every reconnect, and laptops reconnect dozens of times a day.
  • Misses edits and receipts on older messages that aren't on the newest page.

Replay a per-user event log

The client sends { type: "hello", lastEventSeq: 88121 }. The server keeps each user's recent events (say 24 hours or 10,000 events) and sends everything after 88121, in order, before any new live event.

Pros

  • One round trip, carrying only what changed: messages, edits, deletes, receipts, membership.
  • The same upsert rules apply, so replaying is safe even if some events were already seen.

Cons

  • The server must keep a log per user.
  • Useless after a long absence, when the log has rolled over.

Delta sync per conversation

The client asks GET /sync?since=<cursor>. The server returns the conversations that changed, with their newest seq and watermarks. The client then fetches ?after= only for the chats it's showing, and marks the rest stale.

Pros

  • Works after any absence, even weeks.
  • Cost grows with the number of changed chats, not with the number of events.

Cons

  • Two steps instead of one.
  • Doesn't carry edits on old messages; those are fixed when a stale page is next opened.

What we pick: Replay the event log for the common case, a gap of seconds to hours, and fall back to delta sync when the server answers resync_required because the client is too far behind. Refetching everything stays as the last resort, after a protocol version change or corrupted local data.

After hello, the order of work matters. First apply the replay, so the store knows what the server already has. Then flush the outbox in creation order: replayed message.new events have already confirmed some pending sends by clientId, and the server's idempotency check catches the rest.

Then send the current delivered and read watermarks again (they're idempotent), and re-subscribe to presence for the people on screen. Typing pings are never replayed or resent.

Show the connection state honestly but calmly. Don't flash "Connecting..." for a 300 ms blip: show it only after 2 seconds without a connection, and "Waiting for network" when the browser reports offline. The composer stays usable the whole time, because the outbox makes sending offline safe.

Try it: send, drop the connection, let Bob write, lose an ack, and reconnect. Turn resume or deduplication off to see which bug each one prevents.

Send a few messages, drop the connection, let Bob write while you're away, then reconnect.

The server log has 0 messages: none. Your screen shows: nothing. The socket is open; resume is on and dedupe is on.server log (the truth)your screenno messages yetsocket: openoutbox: 0 waitingresume: ondedupe by clientId: on
Send a few messages, drop the connection, let Bob write while you're away, then reconnect.

Check your understanding · Exercise 8

A deploy drops 1 million connections. Which client behaviors keep the servers standing? Select all that apply.

Many tabs, many devices

People open the chat app in several tabs without thinking about it, and they also use it on their phone. Multi-device is mostly the server's job: it sends every event to every connection of every member, including the sender's own other devices. When your phone sends a message, your laptop receives message.new with senderId equal to you and no matching pending entry, so it simply renders it as one of your sent messages. Read state already works across devices, because watermarks live on the server.

Tabs are the client's problem. The naive answer is that each tab acts like its own device with its own socket. It works, and it's where most teams start. Now open five tabs: five sockets and five heartbeats for one person, five copies of every event from the server, five desktop notifications for one message, five tabs flushing the same IndexedDB outbox at once, and five tabs fighting over document.title.

Decision How many connections should one browser hold for one user, and who owns them?

One socket per tab

Every tab connects on its own. The server treats each tab as a device.

Pros

  • The simplest code: no coordination, and a tab never depends on another.
  • A crash in one tab affects nothing else.

Cons

  • Server connections, heartbeats, and fan-out grow with the number of tabs.
  • Duplicate notifications and duplicate outbox flushes unless the tabs coordinate anyway.

A SharedWorker owns the socket

One SharedWorker, a script shared by all tabs of the site, holds the socket and the outbox. Tabs talk to it over MessagePorts.

Pros

  • Exactly one connection, owned by something that isn't a tab.
  • A clean place for the sync engine to live.

Cons

  • Chrome on Android only gained it in version 148 (2026), so older mobile browsers still need another path.
  • Harder to debug, and by default the worker dies when the last tab closes.

A leader tab, chosen with Web Locks

Every tab asks for the same lock with navigator.locks.request. The browser grants it to one tab, the leader, which holds the socket. Others wait in line. The leader relays server events to the other tabs over a BroadcastChannel, and they send their frames to it the same way.

Pros

  • One connection per browser, with APIs that work in every current browser.
  • When the leader closes or crashes, the browser releases the lock and the next tab takes over within milliseconds.

Cons

  • More moving parts: relaying, handover, and frames sent during a handover.
  • The leader may be a hidden tab whose timers are throttled.

What we pick: A leader tab chosen with Web Locks, because it gives one connection per browser on every platform. A SharedWorker is a clean alternative, but older Android browsers would still need a second path. If time is short in the interview, say "one socket per tab first, with BroadcastChannel to elect who notifies" and name leader election as the next step.

ts
const channel = new BroadcastChannel("chat");

// Every tab asks; the browser grants the lock to one tab at a time.
navigator.locks.request("chat-socket", async () => {
  const engine = await SyncEngine.start(); // opens the socket, resumes from IndexedDB
  engine.onEvent((event) => {
    applyToStore(event);             // this tab's own store
    channel.postMessage({ kind: "event", event }); // everyone else's
  });
  channel.onmessage = ({ data }) => {
    if (data.kind === "frame") engine.send(data.frame); // a follower's send, read, typing
  };
  await new Promise(() => {}); // hold the lock until this tab goes away
});

// Followers apply relayed events. A BroadcastChannel never delivers a message
// back to the object that posted it, so the leader doesn't apply its events twice.
channel.addEventListener("message", ({ data }) => {
  if (data.kind === "event") applyToStore(data.event);
});
The lock is released automatically when the leader's tab closes, crashes, or is discarded, and the next waiting tab's callback starts. It resumes from the lastEventSeq in IndexedDB, so nothing is missed.

What stays per tab

Some things can't be centralized. Visibility is per tab: only the tab the user is looking at may report a message as read, so followers tell the leader "I'm visible, focused, and showing chat c_88 up to 1046", and the leader sends the read. Typing starts in whichever tab has the composer focused. Each tab keeps its own in-memory store, fed by the relayed events, so rendering never waits on another tab.

Shared things get exactly one owner. The leader alone writes server events to IndexedDB, flushes the outbox, shows desktop notifications, and updates the app badge. A follower that queues a send writes it to the outbox and posts it to the leader. If a handover happens in between, the new leader finds it in the outbox on startup, and the clientId makes sending it twice harmless.

Try it: open and close tabs, switch models, and have Bob send a message.

Model

Tab 1 is hidden and holds the lock, so it leads. Tab 2 is the one you're looking at.

2 open tabs in the leader tab model: tab 1, leader, hidden; tab 2, visible. Connections to the gateway: 1.Gatewaytab 1leader, hiddentab 2visibleBroadcastChannelconnections: 1model: leader tabpress Bob sends a messageto count frames
Tab 1 is hidden and holds the lock, so it leads. Tab 2 is the one you're looking at.

Check your understanding · Exercise 9

With a leader tab holding the socket, which statements are true? Select all that apply.

Presence and typing at scale

Presence (online, away, last seen) and typing are ephemeral: true for a few seconds, worthless after. That single property decides how to treat them. They're never stored with messages, never written to the outbox, never replayed after a reconnect, and they're the first frames dropped when bufferedAmount says the network is slow.

The naive design pushes every status change to everyone who might care: all of a user's contacts and all members of their groups.

Put numbers on it. A user with 300 contacts changes state about 20 times a day (opens the laptop, goes idle, comes back, closes it). That's 6,000 frames a day caused by one person. Across 50 million daily users, it's 300 billion frames a day, about 3.5 million per second, almost none of which anyone is looking at.

Decision Whose presence should each client receive?

Every contact, pushed

The server pushes every status change of every contact and group member to every one of their connections.

Pros

  • Always up to date, everywhere.
  • No subscription logic in the client.

Cons

  • Fan-out is contacts times changes: millions of frames per second at scale.
  • Most frames update dots that aren't on anyone's screen.

Only the people on screen, subscribed

Each tab sends presence.subscribe with the set of users it is showing: the visible rows of the conversation list and the open chat's header, usually 20 to 40 people. The server pushes changes only for those, and the tab replaces the set as the user scrolls.

Pros

  • Fan-out follows attention: one change goes to the few clients actually showing that person.
  • Large groups don't multiply the cost.

Cons

  • The client must keep the subscription in step with what's on screen.
  • A dot is briefly unknown when a new row scrolls in, until the server answers.

Fetch on demand

The client asks for presence when a chat opens, and refreshes every 60 s.

Pros

  • No push at all for presence. Simple and cheap.

Cons

  • Up to a minute stale, which users notice when someone obviously just replied.
  • Polling traffic for every open chat.

What we pick: Subscribe to the people on screen. Presence only matters where a dot is visible, so the subscription should match the screen. Debounce subscription updates by about 500 ms while the list scrolls, and resubscribe after every reconnect. For groups over about 100 members, show a count ("12 online") from a snapshot refreshed every minute instead of individual dots.

Presence rules that avoid flicker

  • A user is online while any of their devices is connected. When the last connection closes, the server waits about 30 s before announcing offline, so a reload or a short tunnel doesn't blink the dot.
  • The client reports away after 5 minutes without keyboard or pointer input, or when all its tabs are hidden. With a leader tab, followers report their activity to it and it sends one combined status.
  • Last seen is a server timestamp, shown relative ("last seen 5 min ago") and refreshed by a single timer, not one timer per row.
  • Privacy settings apply on the server: if a user hides last seen, the server never sends their timestamp.

Typing: throttle on one side, expire on the other

The naive typing indicator sends a frame on every keystroke, plus a "stopped" frame when the user stops. At 5 keystrokes per second in a 50-member group, that's 250 frames a second from one typist. Worse, if the "stopped" frame is lost, or the tab is closed mid-sentence, the indicator stays on forever.

Flip it around. The sender throttles: it sends typing at most once every 3 seconds while keys are being pressed. The receiver expires: it shows "Asha is typing" and hides it 5 seconds after the last frame, or at once when a message from Asha arrives.

No "stopped" frame is needed, so nothing can get stuck. The server helps too: it sends typing only to connections that have that chat open, which the client reports when the user switches chats.

A typing indicator on a 12-second timeline. Asha presses keys from 0 to 7 seconds, then stops without sending. Her client sends typing frames at 0, 3, and 6 seconds, at most one per 3 seconds. Bob sees Asha is typing from 0 seconds until 11 seconds: 5 seconds after the last frame, it expires.keystypingstops at 7 sframes036at most one per 3 sBob seesAsha is typingexpires at 6 s + 5 s0 s2 s4 s6 s8 s10 s12 s
Bob's client needs no stopped-typing frame: the indicator dies on its own 5 seconds after the last ping, or at once when Asha's message arrives.

Check your understanding · Exercise 10

Which choices keep presence and typing cheap and correct? Select all that apply.

Offline: the outbox and the local copy

Two promises from the requirements live here: a message typed offline is sent when the connection returns, even after a reload, and recent chats open from local data within 300 ms. Memory can't keep either promise, because a reload or a crashed tab wipes it. So the client needs a database in the browser.

Decide what must survive, from most to least important: the outbox (unsent messages; losing one breaks the first promise), the sync cursor (lastEventSeq, so a restart resumes instead of resyncing), drafts per chat, the conversation list, and the newest messages of recent chats. Everything else can be fetched again.

Decision Where should the outbox and the local copy of chats live?

Memory only

Keep everything in the JavaScript store and fetch it again after each load.

Pros

  • Nothing to migrate, clear, or secure on disk.

Cons

  • A reload or a crashed tab loses unsent messages.
  • Every start waits for the network before showing anything.

localStorage

Serialize the outbox and recent messages to JSON strings under a few keys.

Pros

  • One line to read and write, and it's everywhere.

Cons

  • Synchronous: every write blocks the main thread while the whole value is stringified.
  • About 5 MB per site, strings only, no indexes, and not available in workers.

IndexedDB

A transactional database in the browser, with object stores, indexes, and structured values including Blobs. Usually used through a thin wrapper such as idb or Dexie.

Pros

  • Asynchronous, so writes don't block typing.
  • Large quotas, indexes for queries like "outbox in creation order", and transactions for atomic updates.
  • Usable from workers and every tab.

Cons

  • A clunky raw API, and schema migrations across open tabs need care.
  • The browser may still clear it under storage pressure.

What we pick: IndexedDB. The outbox must be written before the send and survive reloads, crashes, and restarts, which rules out memory. localStorage blocks the main thread on every write. IndexedDB's transactions also let the client confirm a send and delete its outbox entry in one atomic step.

The chat database in IndexedDB, version 3, with five stores: outbox keyed by clientId, messages keyed by conversation and seq, meta holding lastEventSeq, conversations indexed by activity, and drafts per chat. The composer writes to the outbox first. The sync engine flushes the outbox and confirms sends into messages. The database is about 3 MB and pruned at startup.Composerwrites firstSync engineflush, confirmIndexedDB: chat, version 3outboxkey: clientIdmessageskey: chat, seqmetalastEventSeqconversationsby activitydraftsper chatabout 3 MB,pruned at start
The outbox is written before anything is sent, so a closed tab can't lose a message. The rest is a cache the server can refill.
ts
import { openDB } from "idb";

export const db = openDB("chat", 3, {
  upgrade(db, oldVersion) {
    if (oldVersion < 1) {
      const outbox = db.createObjectStore("outbox", { keyPath: "clientId" });
      outbox.createIndex("byCreated", "createdAt");
      db.createObjectStore("messages", { keyPath: ["conversationId", "seq"] });
      db.createObjectStore("meta"); // lastEventSeq, userId, schema notes
    }
    if (oldVersion < 2) db.createObjectStore("conversations", { keyPath: "id" }).createIndex("byActivity", "lastActivityAt");
    if (oldVersion < 3) db.createObjectStore("drafts", { keyPath: "conversationId" });
  },
});

// Confirm a send: write the message and drop the outbox entry together.
export async function confirmSend(message: StoredMessage) {
  const tx = (await db).transaction(["messages", "outbox"], "readwrite");
  await Promise.all([tx.objectStore("messages").put(message), tx.objectStore("outbox").delete(message.clientId), tx.done]);
}
Keying messages by [conversationId, seq] makes "the newest 50 of chat c_88" a single range query. Each version step only adds, so a tab on any old version upgrades cleanly.

The details that bite

  • Write before you send. The outbox write happens in the same tick as the optimistic insert. If the tab dies between the two, the worst case is a message that's in the outbox but not yet on screen, and it shows on the next start.
  • Keep it small. Keep the newest 200 messages of the 50 most recently opened chats, about 3 MB, and prune the rest when the app starts. Older history comes from the server.
  • Storage can be cleared. Browsers evict site data under storage pressure, and Safari may delete script-written storage for sites that haven't been used for a week. Call navigator.storage.persist() to ask for durable storage, and treat local data as a cache: the server is the source of truth.
  • Upgrades across tabs. A new version of the app can't upgrade the database while an old tab has it open. Old tabs must listen for versionchange, close their connection, and ask the user to reload.
  • Log out means wipe. Chats on disk are readable by anyone with the computer. On logout, delete the database, the cached app data, and any queued attachments.
  • The app shell offline. A service worker caches the HTML, JavaScript, and CSS, so the app itself opens without a network and then reads IndexedDB.

Check your understanding · Exercise 11

The user writes three messages on a plane, closes the laptop, and opens the app at home. What makes them arrive once each, in order? Select all that apply.

Photos and files

The naive way to send a photo is to put its bytes in the message frame, as base64 text on the socket. Base64 makes it a third larger, and a 20 MB video now sits in the socket's queue in front of every typing ping, receipt, and message behind it. If the connection drops at 90%, the whole upload starts over, and the message can't be retried without sending all 20 MB again.

Separate the bytes from the message. The message stays a small frame that references an attachment, and the bytes travel on their own path, where they can show progress, resume, and fail without blocking chat.

Decision How should a photo or file get from the composer to the other members?

Inline on the socket

Encode the file as base64 inside message.send.

Pros

  • One step, one frame, one ack.

Cons

  • Large frames block everything behind them on the one connection.
  • A third bigger, no resume, and the server must parse huge JSON.

Upload to the chat API with the message

POST /messages as multipart/form-data with the text and the file together.

Pros

  • Keeps chat traffic off the socket while uploading.
  • Upload progress is available.

Cons

  • Every byte passes through the chat servers, which aren't built for bulk data.
  • The message can't be sent until the whole file arrives, and a failure loses both.

Upload straight to storage, then send a reference

The client asks POST /attachments for an attachmentId and a short-lived signed upload URL, uploads the bytes directly to object storage, then sends a normal message.send with attachments: [{ id, width, height, mime, preview }].

Pros

  • Bytes go to storage built for them, close to the user through a CDN.
  • The message frame stays tiny, and uploads can be resumed in chunks.
  • Uploading can start the moment a file is picked, before the user presses Send.

Cons

  • Two steps, so an upload can finish without a message (the server deletes orphans after a day).
  • Signed URLs and their expiry need care.

What we pick: Upload straight to storage, then send a reference. Chat frames stay small and ordered, uploads get progress and resume, and the chat servers never touch file bytes. The outbox entry simply waits in an "uploading" state until its attachments are done.

Sending a photo as a sequence diagram between your tab, the chat server, storage, and Bob's tab. Your tab asks the chat server POST /attachments and gets an id and a signed upload URL. It uploads the bytes straight to storage with PUT, showing progress from 0 to 100 percent, and storage answers 200 OK. Then your tab sends message.send with the attachment id, and the chat server pushes message.new to Bob with the size 4:3 and a tiny preview.Your tabChat serverStorageBob's tabPOST /attachmentsid + upload URLPUT, 0 to 100%200 OKmessage.send + idmessage.new, 4:3
The chat server never touches the file bytes, and the message frame stays small. Bob can lay out the image box from its size before any pixels arrive.
  • Preview at once. Show the picked image with URL.createObjectURL(file) and its upload progress over it. Revoke the URL when the real one arrives.
  • Shrink photos first. In a worker, decode with createImageBitmap, draw onto an OffscreenCanvas at about 1,600 px on the long side, and export JPEG (or WebP, where the browser can encode it) at quality 0.8. A 6 MB phone photo becomes about 300 KB, and the re-encode drops EXIF data such as GPS location.
  • Show progress honestly. fetch has no upload progress event, so use XMLHttpRequest and its upload.onprogress event, or upload in 5 MB chunks and count them. Chunks also make resuming possible.
  • Survive reloads. Store the Blob with the outbox entry in IndexedDB, so a reload resumes the upload instead of losing the photo.
  • Reserve the space. The message carries width, height, and a tiny blurred preview of about 300 bytes. The receiver renders a box with that aspect-ratio and the blurred preview, then the full image with loading="lazy", so nothing in the thread jumps.
  • Keep downloads private. Attachments are served through short-lived signed URLs, not public links, so a forwarded URL stops working within minutes.

Check your understanding · Exercise 12

Which parts of the attachment flow prevent the thread from jumping on the receiver's screen? Select all that apply.

The conversation list and notifications

The conversation list is the app's home screen: every chat with its name, avatar, last message preview, time, and unread badge, newest activity first. Pinned chats stay on top and archived ones are hidden. It loads in pages by a last-activity cursor, though most users' 50 to 300 chats fit in two or three pages.

Every message.new touches it: the chat's preview and time change, its unread count goes up, and it moves to the top. Key rows by conversation id so React moves the existing DOM node instead of rebuilding it. Moving a node can still drop keyboard focus, so if the focused chat moves, put focus back on it. If the user is scrolled down the list, moving rows above them shifts what they see, so the list keeps its scroll anchored to the first visible row, the same way the thread does.

A conversation list. Family is pinned on top. Team chat just received a message, moved to second place, and shows 5 unread. Asha shows 2 unread. Bob shows a draft. Lena is muted with 9 unread in a grey badge. Notes say: a new message moves the chat to the top and adds one to its count; unread counts come from the server and add one per message; muted chats get a grey badge and no notification.ChatsFamily (pinned)Team chat 5Asha 2Bob, draft: okLena, muted 9new message: moves up,under pins, count + 1unread from the server,+1 per incoming messagemuted: grey badge,no notification
Rows are keyed by conversation id, so a chat that moves up keeps its DOM node and state. Pinned chats stay above everything else.

Unread counts

It's tempting to compute unread as lastSeq - myReadSeq. It's close but wrong: my own messages, deleted messages, and system lines ("Asha added Bob") have a seq but aren't unread.

So the server sends unreadCount and mentionCount with each conversation, the client adds one for each incoming message from someone else, and sets both to zero when its read watermark reaches lastSeq. Any receipt for my own readSeq, from any device, recomputes them. Show "99+" past 99, and grey the badge for muted chats.

Notifications: three situations

  • The chat is open and the tab is visible. No notification. At most a soft sound if the user enabled one.
  • The tab is hidden. The leader tab shows a Notification with the sender and text, and tag set to the conversation id, so a burst of five messages replaces one notification instead of stacking five. Clicking it focuses the tab and opens the chat. The tab title becomes "(3) Chat".
  • No tab is open. The server sends a Web Push message to the browser's push subscription, and the service worker shows the notification. Clicking it uses clients.matchAll to focus an existing tab or clients.openWindow to open one.

Two rules keep notifications pleasant. Ask for permission after a user action that explains the value, like a "Turn on notifications" button, never on first page load: browsers punish sites whose prompts are often dismissed by quieting them.

Second, let the server skip the push for a browser that already has a live connection, since its leader tab shows the notification. The service worker can't do this filtering itself, because Chrome requires every push to show a notification. For installed web apps, navigator.setAppBadge(3) puts the count on the app icon where the Badging API is supported.

Check your understanding · Exercise 13

Asha has the app open in two hidden tabs and gets 5 messages in a group in 10 seconds. What should she see? Select all that apply.

Putting it together: the architecture

Each chapter so far added a responsibility. Now group them into parts with clear boundaries. The rule that shapes everything: the UI never touches the network.

Views read the store and call actions. Only the sync engine talks to the socket, only the history loader fetches pages, and only the persistence layer touches IndexedDB. That way the hard rules (ordering, dedupe, idempotency, resume) live in one place each, and can be tested without a browser.

The chat app's architecture. In the browser tab, Views send actions to the Chat store, which keeps messages by seq and is saved to IndexedDB through the persistence layer. The History loader fetches before, after, and around pages over HTTPS from the Chat API and writes them into the store. The Sync engine holds the WebSocket to the Gateway, writes events into the store, and works with the Tab coordinator, which holds the Web Lock and relays events to other tabs. The Upload manager sends files straight to Storage.Browser tabViewslist, thread, composerChat storemessages by seqIndexedDBoutbox, cacheHistory loaderbefore, after, aroundSync enginesocket, outbox, resumeTab coordinatorlock, relayUpload managerresize, chunksactionsServersChat APIhistory, ticketsGatewayfan-outStoragefiles via CDNHTTPSWSS
Three network paths: live events over one WebSocket, history over HTTPS, and file bytes straight to storage. The views never touch any of them; they only read the store.
PartOwnsTalks to
ViewsConversation list, thread, composer, message actions. Local UI state like the composer text.Reads the store; calls actions.
Chat storeConversations, messages by seq, pending by clientId, receipts, loaded ranges, presence, typing.Updated only through actions and the upsert rules.
Sync engineThe socket, tickets, heartbeats, backoff, hello and replay, the outbox flush, acks.Gateway over WSS; the store; persistence; other tabs.
Tab coordinatorWeb Locks leadership and the BroadcastChannel relay.Sync engine in the leader; stores in every tab.
History loaderbefore, after, and around pages; one request per direction.Chat API over HTTPS; the store.
Upload managerResizing, signed URLs, chunked uploads, progress.Object storage; the outbox.
PersistenceIndexedDB schema, the outbox, recent messages, cursors, drafts.Store and sync engine.
Service workerThe cached app shell and Web Push notifications.Push service; open tabs.

Why are live events and history on different paths? The socket carries small, frequent, ordered events. History pages are large request-and-response reads, and plain GETs get HTTP compression, caching, and simple retries for free. Putting a 50 KB page on the socket would make live messages wait behind it, and every history request would need its own ack matching.

Why is the sync engine separate from the store? Because it's the part that changes with the transport. Swap WebSocket for long polling and only the engine changes; it still feeds the same upsert rules. With a leader tab, it runs in one tab while every tab has a store.

Data model: what lives where

Most of the model has already appeared, one decision at a time. Collected, it has two kinds of data: server truth that the client caches (conversations, messages, receipts) and client-only state (pending sends, loaded ranges, presence, typing). Each piece has one home, and the table says where.

ts
type Message = {
  id: string | null;          // server id; null while pending
  seq: number | null;         // order in the chat; null while pending
  clientId?: string;          // set on messages sent by me
  conversationId: string;
  senderId: string;
  body: string;
  attachments: { id: string; mime: string; width?: number; height?: number; preview?: string }[];
  createdAt: string;          // server time, for display only
  version: number;            // goes up with each edit or delete
  deleted?: boolean;          // tombstone: keeps seq gapless
  status?: "queued" | "uploading" | "sending" | "failed"; // client only
};

type Conversation = {
  id: string; kind: "dm" | "group"; title: string; memberIds: string[];
  lastSeq: number; lastActivityAt: string; lastPreview: string;
  myReadSeq: number; unreadCount: number; mentionCount: number;
  muted: boolean; pinned: boolean;
};

type MemberReceipt = { conversationId: string; userId: string; deliveredSeq: number; readSeq: number };

type ChatView = {             // client only, per conversation
  loadedRanges: [number, number][];
  anchor: { seq: number; offsetPx: number } | "bottom";
  draft: string;
};
The status field exists only on the client. A confirmed message never has one: its tick is derived from receipts.
DataLives inWhy there
Conversations, recent messagesChat store, copied to IndexedDBRendered everywhere; the copy makes startup instant.
Older history pagesChat store, in loaded rangesFetched on demand, dropped from memory when far away.
Pending sendsChat store and the IndexedDB outboxShown at once; must survive a reload.
Receipts (watermarks)Chat storeTwo numbers per member; refetched with the conversation.
lastEventSeqIndexedDBRead by whichever tab leads, to resume.
Presence, typingMemory onlyEphemeral: stale after seconds, never persisted.
Open chat, jump targetURL (/c/c_88?m=812)Back, refresh, and shared links land on the same place.
Composer textComponent state, saved to drafts on pauseTyping must not re-render the store's subscribers.
The data model in two groups. Server truth, cached on the client: Conversation with lastSeq, myReadSeq, and unread counts; Message with id, seq, clientId, and version; MemberReceipt with deliveredSeq and readSeq. Client only: a pending send with clientId and status, which becomes a Message when the ack arrives; ChatView with loaded ranges and the scroll anchor; and presence and typing, kept in memory and expiring.Server truth, cachedConversationlastSeq, myReadSeq, unreadMessageid, seq, clientId, versionMemberReceiptdeliveredSeq, readSeqClient onlyPending sendclientId, statusChatViewloadedRanges, anchorPresence, typingmemory, expires
A pending send is the only client entity that turns into server truth: the ack gives it an id and a seq, and it keeps its clientId as its key.

Note what's missing: no tick field, no unread flag per message, no "isMine" field. Each is derived from something else (receipts, myReadSeq, senderId), and every stored copy of a derived value is a value that can drift out of date.

The interface: HTTP, socket frames, and components

The API has two halves that match the two paths. Plain HTTPS handles everything that's a request for data: tickets, the conversation list, history pages, sync, and attachments. The socket handles everything that's live: sends, acks, pushes, receipts, typing, and presence.

CallPurposeShape
POST /realtime/ticketA single-use socket ticket{ ticket, url, expiresIn: 30 }
GET /conversations?before=cursorThe conversation list, by last activity{ conversations: Conversation[], nextCursor }
GET /conversations/:id/messagesHistory: before, after, or around a seq, limit=50{ messages: Message[], hasBefore, hasAfter }
GET /sync?since=cursorDelta sync after resync_required{ changed: Conversation[], cursor }
POST /attachmentsReserve an upload{ attachmentId, uploadUrl, expiresAt }
ts
type ClientFrame =
  | { type: "hello"; lastEventSeq: number }      // the ticket and version are in the URL
  | { type: "message.send"; clientId: string; conversationId: string; body: string; attachmentIds?: string[] }
  | { type: "read" | "delivered"; conversationId: string; seq: number }
  | { type: "typing"; conversationId: string }
  | { type: "focus"; conversationId: string | null }      // which chat this client shows
  | { type: "presence.subscribe"; userIds: string[] }   // replaces the previous set
  | { type: "ping" };

type ServerFrame =
  | { type: "welcome"; replayed: number } | { type: "resync_required" }
  | { type: "ack"; clientId: string; id: string; seq: number; createdAt: string }
  | { type: "error"; clientId?: string; code: string; retryAfter?: number }
  | { type: "message.new" | "message.updated"; eventSeq: number; message: Message }
  | { type: "receipt"; eventSeq: number; conversationId: string; userId: string; deliveredSeq: number; readSeq: number }
  | { type: "typing"; conversationId: string; userId: string }
  | { type: "presence"; userId: string; status: "online" | "away" | "offline"; lastSeenAt?: string }
  | { type: "pong" };
The whole live protocol as two unions. A switch on type in the sync engine gets full type checking, and frames without an eventSeq (typing, presence) are exactly the ones never replayed.

Errors on HTTP use one JSON shape, { error: { code, message, retryAfter? } }, with the same codes as socket errors, so the retry logic is shared. For components, the props mirror the store: MessageList takes conversationId, anchor, onReachTop, onReachBottom, and onVisibleRange (which feeds read receipts), and renders rows through a renderMessage prop so text, media, and system lines share one virtualizer. Composer takes conversationId and onSend(body, files), and owns its own text state.

Performance: a virtual list anchored to the bottom

A chat thread is a list that grows at both ends and is read from the bottom. Each message is 20 to 60 DOM nodes (bubble, text, time, ticks, reactions, menu button). A user who scrolls back through 5,000 messages would put 150,000 nodes in the page, and every new message, receipt, or typing change would restyle all of them. On a mid-range laptop, taps start to lag past a few thousand messages.

Feeds start at the top; chat starts at the bottom, which breaks the usual tools. When new messages arrive and the user is at the bottom, the view must follow them down. When older pages load above, the view must not move at all. And opening a chat must land at the bottom (or the first unread) on the first frame, not after a visible scroll.

Decision How should the thread render thousands of messages while staying anchored to the bottom?

Render everything with CSS help

Render every loaded message, with content-visibility: auto and contain-intrinsic-size so the browser skips layout and paint for off-screen ones.

Pros

  • Real DOM for every message: find-in-page and screen readers see everything.
  • Very little code.

Cons

  • The DOM and memory still grow without limit; only rendering work is saved.
  • Every React update still walks thousands of components.

A reversed flex column

Lay the thread out with flex-direction: column-reverse, so the browser naturally keeps the scroll at the bottom as content is added.

Pros

  • Sticking to the bottom and keeping position on prepend come almost for free.

Cons

  • Hard to combine with virtualization, and scroll positions count from the bottom, which confuses libraries and code.
  • DOM order and visual order differ, which complicates keyboard and screen reader order.

A virtualized list anchored to the bottom

Render only the messages near the viewport plus a buffer, usually 30 to 50 rows, inside a spacer that keeps the total height. Measure each row with ResizeObserver, cache heights by key, and correct scrollTop whenever rows change above the viewport. react-virtuoso has options for prepending and following new items; TanStack Virtual gives you the measuring pieces to build it.

Pros

  • Constant DOM size whether 50 or 50,000 messages are loaded.
  • Full control over anchoring at the bottom and on prepend.

Cons

  • Find-in-page can't see unrendered messages, so search must be in-app.
  • Height measurement and scroll correction are subtle to get right.

What we pick: A virtualized list anchored to the bottom, because long histories are normal and DOM size must stay flat. For short chats it renders everything anyway. In-app search covers find-in-page, and messages render as articles with proper names, so screen readers move through them well.

A virtualized chat thread. At the top, an older page has just been inserted, adding 600 px. Below it, a spacer stands in for loaded messages 1101 to 1195. The viewport shows messages 1196, 1197, and 1198, with the newest message at the bottom. Notes say: when prepending, add 600 to scrollTop before paint; if the user is within 100 px of the bottom, follow new messages; only 30 to 50 rows are in the DOM, with heights cached by key.older page: +600 pxspacer: 1101 to 1195119611971198: newestviewport, anchored at the bottomprepend: scrollTop+= 600 before paintwithin 100 px of bottom?follow new messages30 to 50 rows in DOM,heights cached by key
The reader never sees a jump: inserting above adds the same height to scrollTop in the same frame, and new messages only pull the view down when the user is already at the bottom.

The anchoring rules

  1. Stick to the bottom only if already there. Before applying a new message, check whether the user is within about 100 px of the bottom. If yes, scroll to the new bottom after rendering. If no, leave the scroll alone and count the message on the "Jump to latest" button.
  2. My own send always scrolls down. If I press Enter while reading old messages, I want to see what I sent.
  3. Prepend without moving. Before inserting an older page, record the first visible message's key and its distance from the top of the viewport. After rendering, in useLayoutEffect (before the browser paints), set scrollTop so that distance is the same.
  4. Correct for growth above. When an image or link preview above the viewport finishes loading and grows by 120 px, add 120 px to scrollTop in the same frame. Known image sizes make this rare.
  5. Remember the anchor per chat. Switching chats saves { seq, offsetPx } or "bottom", so coming back restores the exact place.

Staying fast for hours

  • Keep typing local. The composer owns its text in component state. Typing must never re-render the thread, or each keystroke pays for hundreds of rows and INP goes past 200 ms.
  • Subscribe narrowly. Each message row subscribes to its own message and the receipt it needs, so one receipt update re-renders only the rows whose tick changes.
  • Load features on demand. The emoji picker, media viewer, and message info panel are split into chunks loaded on first use. The first screen needs only the list, thread, and composer.
  • Drop what's far away. Keep at most about 2,000 messages per chat in memory, and drop ranges far from the anchor. They come back from IndexedDB or the server if the user scrolls there again.
  • Measure in the field. Track INP on send and on chat switch, time from Enter to bubble, time from message.new to paint, and long tasks during history loads.

Check your understanding · Exercise 14

The user is reading messages from yesterday, 3,000 px above the bottom, when two things happen: a new message arrives, and an older page loads above. What should the list do? Select all that apply.

Accessibility: hearing and moving through a conversation

A chat app is a live region by nature: content appears without the user asking. Screen reader users need to hear new messages in the open chat without losing their place, to move through the history message by message, and to reach the composer from anywhere. Keyboard users need the same paths without a mouse.

The obvious move is to put role="log" on the thread, since a log is a live region for content added in order. Be careful with a virtualized, two-way list: rows added at the top when history loads, and rows recycled during scrolling, would be announced too. A more predictable pattern is a visually hidden, separate live region with aria-live="polite" that the app writes to only for new incoming messages in the open chat, like "Asha: running 10 min late". The thread itself stays a plain, well-labelled list of articles.

  • Name every message. Each row is an article whose accessible name includes the sender, the text, the time, and for my messages the state in words ("read"), since ticks are only pictures.
  • Announce politely and briefly. Batch bursts ("3 new messages from Asha") and never announce typing, presence, or receipts.
  • Three keyboard zones. Tab moves between the conversation list, the thread, and the composer. Inside the list and the thread, arrow keys move one item at a time with a roving tabindex, Enter opens a chat or a message's actions, and Escape returns to the composer.
  • Move focus deliberately. Opening a chat focuses the composer and announces the chat's name. Jumping to a message moves focus to that message. Loading history never moves focus.
  • Respect settings. Honor prefers-reduced-motion for typing dots and new-message animations, keep everything usable when text is resized to 200%, and keep tick colors distinguishable for color-blind users with shape as well as color.

Check your understanding · Exercise 15

Which choices help a screen reader user follow a busy group chat? Select all that apply.

Follow-up: end-to-end encryption

Interviewers often end with "What if messages must be end-to-end encrypted?" End-to-end encryption (E2EE) means only the members' devices can read a message: the server stores and forwards ciphertext it can't decrypt. TLS, which we already use, protects messages only on the way to the server; the server itself can read them.

Most of this design survives. Envelopes, clientId, seq, acks, watermarks, replay, and the outbox all work on ciphertext, because the server never needed to read the body to order or deliver it. What moves is everything that did read the body.

Decision How far should encryption go?

TLS plus encryption at rest

Messages are encrypted on the wire and on the server's disks, but the server holds the keys.

Pros

  • Server-side search, link previews, push text, and moderation all keep working.
  • A new device sees full history at once.

Cons

  • The company, or anyone who breaks into its servers, can read messages.

E2EE with per-device keys

Each device generates its own key pair and publishes the public half. Senders encrypt each message for every device of every member, using a protocol such as Signal's (a key agreement plus the Double Ratchet, which changes keys with every message). Groups use sender keys or Messaging Layer Security (MLS), so one message isn't encrypted hundreds of times.

Pros

  • Only members' devices can read messages, even if the server is compromised.
  • Old messages stay safe if a current key leaks, thanks to the ratchet.

Cons

  • Search, previews, and notification text move to the client.
  • A new device can't read old history unless another device transfers it.
  • On the web, the server ships the JavaScript that holds the keys, so code integrity becomes a concern.

What we pick: For the interview's main design, TLS plus encryption at rest, as agreed in the requirements. Explain that the protocol is already E2EE-ready, because ordering and delivery never read the body, and list what would move to the client. Don't design a cryptographic protocol on a whiteboard; name Signal's protocol or MLS and focus on the client consequences.

  • Keys live on the device. Private keys are stored in IndexedDB, as non-extractable CryptoKey objects where Web Crypto supports the algorithm, and crypto runs in a worker so it doesn't block typing.
  • Search becomes local. The server sees ciphertext, so search runs over messages decrypted on this device, in a local index.
  • Notifications decrypt in the service worker. The push payload carries the message still end-to-end encrypted, and the service worker decrypts it with the device's keys to show the sender and text.
  • Key changes are visible. When a contact's device keys change, the chat shows "security code changed" so users can verify, which guards against a server swapping keys.
  • Code integrity. Because a web app is downloaded on every visit, some products publish hashes of their bundles so an extension can check that the served code is the expected code.

Check your understanding · Exercise 16

If messages become end-to-end encrypted, which parts of this design must change? Select all that apply.

Running it in the interview

This guide holds more than fits in 45 minutes. The interview is about choosing what to say, and tying each choice to a requirement.

MinutesWhat to doWhat's on the board
0 to 5Scope questions: group size, devices and tabs, history, media, offline, encryption.500 ms, zero lost or doubled, 2 s to recover, 300 ms to open
5 to 10Name the forces, choose the transport in two sentences, draw the architecture.Views, store, sync engine, history loader, persistence, servers
10 to 30Go deep on three: the protocol with clientId and seq, sending with idempotency and the outbox, and reconnect with resume and jitter.An envelope, the lost-ack sequence, a backoff sketch
30 to 38Receipts as watermarks, history with ranges, tabs with a leader.Two watermarks, a range diagram
38 to 45Presence and typing limits, performance, accessibility, then follow-ups.A short list of what you'd measure

Draw the architecture before any detail, because every deep dive points at a box in it. Then offer a choice: "The hardest parts are sending without losing or doubling, and recovering after disconnects. I'd like to go deep there first, then receipts and history. Does that match what you want to see?" If the interviewer has no preference, start with the lost-ack scenario: it's concrete, it shows the idempotency idea that most candidates miss, and it leads naturally into the outbox and resume.

Say the trade-off out loud every time: the naive version, the scenario where it breaks with a number, the options, and the pick. "If the ack is lost in a tunnel, the client can't know whether the server stored the message, so I resend with the same clientId and the server dedupes" is a senior sentence. "I'd use WebSockets and retries" names the same tools without the reasoning.

Think first

The interviewer asks: "How would you add edits and emoji reactions?"

Think first

The interviewer asks: "How would you test this?"

Check your understanding · Exercise 17

Ten minutes in, you've drawn the architecture. Which next moves are strong? Select all that apply.

Summary

  • Use one WebSocket for small, frequent, two-way events, with a ticket for auth, an app-level heartbeat every 25 s, and long polling behind the same interface as a fallback. Fetch history over plain HTTPS.
  • Give every frame an envelope with a type, and keep three ids apart: clientId (is this my send?), a per-chat seq (where does it go?), and a per-user eventSeq (what have I missed?).
  • Show sends at once, write them to an IndexedDB outbox, and resend with the same clientId: at-least-once delivery plus server deduplication looks exactly-once to users.
  • Merge every source (optimistic insert, ack, push, replay, history page, cache) through one upsert keyed by clientId and seq, and derive the order from seq, never from clocks or arrival.
  • Model delivered and read as two watermarks per member, applied with Math.max, and send read only for messages that are really visible in a focused tab.
  • Page history with before, after, and around a seq, keep loaded ranges, and jump to any message with one request.
  • Reconnect with exponential backoff and full jitter, resume by replaying events after lastEventSeq, fall back to delta sync, then flush the outbox in order.
  • One browser, one socket: a leader tab chosen with Web Locks relays events over BroadcastChannel, while visibility and typing stay per tab. Presence and typing are ephemeral, subscribed to what's on screen, throttled on send and expired on receipt.
  • Render the thread with a virtualized list anchored to the bottom: stick only when already there, correct scrollTop on prepend in the same frame, and keep typing state local to the composer.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next