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.
| Question | Answer we assume | What 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. |
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Most traffic is tiny signals. Typing, presence, and receipts outnumber messages by about ten to one, and they are worthless a few seconds late.
- 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.
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?
On a good network, nothing for a while. The first visible bug is usually a ghost: the laptop sleeps, the socket looks open but is dead, and messages from Bob never arrive until a reload. That happens the first morning someone opens the lid.
Next come duplicates and holes. A send whose confirmation was lost gets retried and appears twice, or isn't retried and is lost. Messages missed during the outage never show up, and two messages sent at the same moment appear in different orders on different screens. Each needs its own fix: heartbeats, idempotent sends, a resume protocol, and server sequence numbers.
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".
EventSource sends Last-Event-ID by itself.Decision How should the browser and the server exchange live messages in both directions?
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.
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);
};
}
visibilitychange and online find a dead line in about 10 s instead of up to 35 s.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.
// 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" } }
clientId ties all three together, wherever and in whatever order they arrive.clientId for sends, seq for order, eventSeq for catching up.Three ids, three jobs
clientId: made by the sender's browser withcrypto.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?
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 code | Meaning | What the client does |
|---|---|---|
rate_limited | Too many sends; carries retryAfter in seconds. | Pause that chat's queue, then resend with the same clientId. |
unavailable | The server couldn't store it right now. | Resend with backoff. The same clientId makes it safe. |
forbidden | You're not a member any more, or you're blocked. | Mark the message failed. No retry. |
too_large | The body is over the 4,000 character limit. | Mark failed and tell the user. The composer should have caught it. |
version_unsupported | The 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?
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.
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
- The user presses Enter. The composer makes a
clientIdand clears the text box. - The store adds the message with
status: "sending"andseq: null. React renders it in the same frame, with a clock icon. - The outbox, a store in IndexedDB, saves the message. From now on, closing the tab can't lose it.
- If the socket is open, the sync engine sends
message.send. If not, the message waits as queued and the clock icon stays. - The ack arrives. The store fills in
id,seq, and the server'screatedAt, drops the client-onlystatus(the tick now shows sent), and moves the bubble to itsseqposition. The outbox entry is deleted. - Later, receipts from the other members move it to delivered and read (next chapters).
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.
| Source | What it carries | How it merges |
|---|---|---|
| Optimistic insert | clientId, body, no seq | New pending entry keyed by clientId. |
| Ack | clientId, id, seq | Finds the pending entry by clientId, fills in ids, keeps the same key. |
Live message.new | Full message with seq; clientId if it's one of mine | Matches a pending entry by clientId first, then by id; otherwise inserts. |
| Replay after reconnect | Same as live | Same rule. Events already applied are skipped by eventSeq. |
| History page | 50 full messages | Upsert each by id, and record the loaded range. |
| IndexedDB on startup | Recent messages and the outbox | Loaded first; everything later merges over it. |
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),
];
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?
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.
read 44, no matter how many messages that covers.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?
His phone's read moves Bob's own readSeq on the server, and the server sends that receipt to all of Bob's connections, not only to the other members. The laptop updates its copy of myReadSeq and the unread count becomes zero.
With per-device read state, every other device would keep showing the chat as unread, and notifications would keep firing for messages Bob has already seen on his phone. Read state is about the person, so it lives on the server.
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?
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.
around creates one, before and after grow it, and touching ranges merge into one.Opening a chat
- Show the newest 50 messages from IndexedDB at once, if they're cached, so the chat paints in under 100 ms.
- If the chat has unread messages, ask
?around=myreadSeq+ 1, so the first unread message is in the middle of the page. Otherwise ask?after=the newest cachedseqto catch up. - 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.
- 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.
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);
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?
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.
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?
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.
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);
});
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.
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?
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.
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?
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.
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]);
}
[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?
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.
- 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 anOffscreenCanvasat 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.
fetchhas no upload progress event, so useXMLHttpRequestand itsupload.onprogressevent, or upload in 5 MB chunks and count them. Chunks also make resuming possible. - Survive reloads. Store the
Blobwith 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 thataspect-ratioand the blurred preview, then the full image withloading="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.
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
Notificationwith the sender and text, andtagset 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.matchAllto focus an existing tab orclients.openWindowto 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.
| Part | Owns | Talks to |
|---|---|---|
| Views | Conversation list, thread, composer, message actions. Local UI state like the composer text. | Reads the store; calls actions. |
| Chat store | Conversations, messages by seq, pending by clientId, receipts, loaded ranges, presence, typing. | Updated only through actions and the upsert rules. |
| Sync engine | The socket, tickets, heartbeats, backoff, hello and replay, the outbox flush, acks. | Gateway over WSS; the store; persistence; other tabs. |
| Tab coordinator | Web Locks leadership and the BroadcastChannel relay. | Sync engine in the leader; stores in every tab. |
| History loader | before, after, and around pages; one request per direction. | Chat API over HTTPS; the store. |
| Upload manager | Resizing, signed URLs, chunked uploads, progress. | Object storage; the outbox. |
| Persistence | IndexedDB schema, the outbox, recent messages, cursors, drafts. | Store and sync engine. |
| Service worker | The 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.
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;
};
status field exists only on the client. A confirmed message never has one: its tick is derived from receipts.| Data | Lives in | Why there |
|---|---|---|
| Conversations, recent messages | Chat store, copied to IndexedDB | Rendered everywhere; the copy makes startup instant. |
| Older history pages | Chat store, in loaded ranges | Fetched on demand, dropped from memory when far away. |
| Pending sends | Chat store and the IndexedDB outbox | Shown at once; must survive a reload. |
| Receipts (watermarks) | Chat store | Two numbers per member; refetched with the conversation. |
lastEventSeq | IndexedDB | Read by whichever tab leads, to resume. |
| Presence, typing | Memory only | Ephemeral: stale after seconds, never persisted. |
| Open chat, jump target | URL (/c/c_88?m=812) | Back, refresh, and shared links land on the same place. |
| Composer text | Component state, saved to drafts on pause | Typing must not re-render the store's subscribers. |
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.
| Call | Purpose | Shape |
|---|---|---|
POST /realtime/ticket | A single-use socket ticket | { ticket, url, expiresIn: 30 } |
GET /conversations?before=cursor | The conversation list, by last activity | { conversations: Conversation[], nextCursor } |
GET /conversations/:id/messages | History: before, after, or around a seq, limit=50 | { messages: Message[], hasBefore, hasAfter } |
GET /sync?since=cursor | Delta sync after resync_required | { changed: Conversation[], cursor } |
POST /attachments | Reserve an upload | { attachmentId, uploadUrl, expiresAt } |
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" };
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?
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.
scrollTop in the same frame, and new messages only pull the view down when the user is already at the bottom.The anchoring rules
- 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.
- My own send always scrolls down. If I press Enter while reading old messages, I want to see what I sent.
- 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), setscrollTopso that distance is the same. - Correct for growth above. When an image or link preview above the viewport finishes loading and grows by 120 px, add 120 px to
scrollTopin the same frame. Known image sizes make this rare. - 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.newto 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
articlewhose 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-motionfor 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?
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
CryptoKeyobjects 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.
| Minutes | What to do | What's on the board |
|---|---|---|
| 0 to 5 | Scope 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 10 | Name the forces, choose the transport in two sentences, draw the architecture. | Views, store, sync engine, history loader, persistence, servers |
| 10 to 30 | Go 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 38 | Receipts as watermarks, history with ranges, tabs with a leader. | Two watermarks, a range diagram |
| 38 to 45 | Presence 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?"
As events in the same stream, so replay covers them. An edit is message.updated with the same id and a higher version; the upsert ignores older versions, so late copies can't undo it. A reaction is its own small write with its own clientId, sent as "set my reaction to X" rather than "toggle", so a retried frame can't flip it back.
Reactions are frequent, so the server sends counts per emoji plus whether I reacted, and loads the full list of who reacted on demand, the same trick as group read receipts.
Think first
The interviewer asks: "How would you test this?"
Most of the risk is in pure logic, so test it without a browser: the upsert rules (any arrival order gives the same list), watermarks with Math.max, range merging, the backoff function, and the outbox flush order. Run them against a fake socket that can drop, delay, duplicate, and reorder frames.
Then end-to-end tests with two browser contexts as two users: send while offline, reload, reconnect, and check both sides see each message exactly once. Add a soak test that leaves a tab open for hours with a heartbeat and checks memory stays flat, plus a manual pass with a screen reader.
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-chatseq(where does it go?), and a per-usereventSeq(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
clientIdandseq, and derive the order fromseq, never from clocks or arrival. - Model delivered and read as two watermarks per member, applied with
Math.max, and sendreadonly for messages that are really visible in a focused tab. - Page history with
before,after, andaroundaseq, 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
scrollTopon 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.
References
- RFC 6455: The WebSocket Protocol The handshake, frames, close codes, and the
Originheader that servers should check. - The WebSocket API, MDN The browser interface, including
bufferedAmountand close events. - Using server-sent events, MDN The main alternative transport, with
Last-Event-IDresume. - Exponential backoff and jitter, AWS Architecture Blog Simulations showing why randomness beats plain backoff under contention.
- Web Locks API, MDN How to elect one leader tab that holds the connection.
- BroadcastChannel, MDN Relaying events and frames between tabs of the same site.
- Using IndexedDB, MDN Object stores, indexes, transactions, and version upgrades for the outbox.
- Page Lifecycle API, Chrome for Developers Hidden, frozen, and discarded tabs, and the events to recheck the connection.
- Push API, MDN Notifying users when no tab is open, through a service worker.
- The Double Ratchet Algorithm, Signal The core of end-to-end encrypted messaging, for the follow-up.
Concepts behind it
- WebSocketsOne long-lived connection, opened by an HTTP handshake, on which browser and server can each send a message at any moment. The usual pick for chat, multiplayer, and collaboration, and the one where you own the protocol, reconnects, and scaling.
- Choosing a real-time techniqueHow to pick between short polling, long polling, server-sent events, WebSockets, and WebRTC for each feature, starting from who talks, how often, and how fresh the data must be, then checking scale, proxies, battery, and the fallback you will ship.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- IndexedDBThe browser's built-in transactional database: object stores of structured values, indexes for queries, versioned migrations, and room for hundreds of megabytes, which makes it the place for offline data, drafts, and large blobs.
- Presence and live cursorsShowing who else is in a shared document and where they are pointing, in near real time: a stream of small, short-lived updates that must stay cheap, smooth, and honest about who has left.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.