cf.completefrontend
My progress
SYSTEM DESIGN · #50

Design live comments for a sports stream

The live chat rail next to a sports stream, where thousands of viewers post at once and a goal can push the rate to thousands of messages per second. The hard parts are showing a readable stream instead of a blur, rendering it without hurting the video, keeping the scroll where the reader wants it, sending with instant feedback under slow mode and moderation, and not spoiling a goal the viewer has not seen yet.

The question

Design the live comments panel that sits next to a live sports stream, like YouTube live chat or Twitch chat. Viewers read what others are saying in real time and post their own short messages, emotes, and reactions.

A big match has 2 million concurrent viewers. When a goal is scored, people post thousands of messages per second. Walk me through the client: how messages reach the browser, how you render them without hurting the video, how scrolling and sending work, how moderation shows up, and what a viewer actually sees during that goal.

Real-life examples: YouTube live chat, Twitch chat, Facebook Live comments, Kick chat, live comment rails on sports streaming apps

Requirements: what a live comment rail must do

A live comment rail looks like a chat app squeezed into a sidebar. It is a different problem. In a chat app such as Slack or WhatsApp, you know most people in the room, every message matters, and you can scroll back through years of history.

In a live rail, you know nobody. Most messages are cheers, jokes, and emotes that matter for 10 seconds. The room can hold millions of people, and the history that matters is the last minute or two.

That changes the goal. A chat app must deliver every message, in order, forever. A live rail must deliver the feeling of a crowd: a fast, readable, safe stream of reactions that keeps up with the match, while the video next to it keeps playing smoothly.

Open the interview with questions whose answers move the design. The table shows the answers this guide assumes and what each one changes.

QuestionAnswer we assumeWhat it changes
How many viewers, and how many post?2 million concurrent at peak; about 1 to 3 percent post at least once a minute.Fan-out through an edge layer; the client must cope with far more messages than it can show.
What is the peak message rate?About 50 to 200 per second on a normal match; 2,000 to 5,000 per second for 30 seconds after a goal.Sampling or summarizing on the server, batching on the client, and a capped list.
Is it chat or threaded comments?Flat chat: no replies, no threads. Short messages up to 200 characters.An append-only list, not a tree like a comments thread.
Does the rail need full history?No. A late joiner sees the last 50 to 100 messages. Replays of past matches are a separate feature.We can drop old messages from memory and accept small gaps on reconnect.
What can a viewer post?Text, emotes, mentions, and one-tap reactions. No images or links for normal users.Emote parsing, link blocking, and a reaction path that is not a message.
Who moderates?Automatic filters on the server plus human moderators with delete, timeout, and ban.Messages can disappear after they render; our own message can be held or rejected after we show it.
Which devices?Phones are 60 percent of viewers; mid-range Android is the baseline.A tight main thread budget next to a playing video, and a small memory footprint for a 3-hour match.
How far behind live is the video?Normal HLS or DASH latency: 10 to 30 seconds behind the stadium. Some viewers use low-latency mode at 2 to 5 seconds.Chat can spoil goals. We may delay chat to match each viewer's player.
A wireframe of a sports stream page. On the left, the video player with a react button under it and a note that reaction icons float up. On the right, the chat rail: a pinned message about the kick-off at the top, tabs for Top chat (selected) and Live chat, three messages, a summary row GOAL x 900, a highlighted mention @mia22 told you!, a row that says message removed, a bar that says Chat paused: 37 new, and the composer with a slow mode countdown of 7 seconds. Notes say the pinned message stays outside the list, summary rows replace repeats, removed rows are replaced in place by id, and the paused bar is a real button.live.sports.examplevideo player+ reacticons float upPinned: kick-offTop chatLive chatria: what a savedevo: offside?lena: unrealGOAL x 900@mia22 told you!message removedChat paused: 37 newSay something7spin stays outsidethe listsummary rowsreplace repeatsremoved in placeby message idpaused bar isa real button
The rail is a short, capped list between two fixed bars: the pin and tabs on top, the paused bar and composer at the bottom. Only the middle scrolls.

Functional requirements

  • Show a stream of messages in the order the server accepted them, newest at the bottom, with author name, badges, and emotes.
  • Two views: a filtered Top chat that hides likely spam and low-value messages, and the unfiltered Live chat. YouTube offers this switch under the same names.
  • Post a message with instant feedback, under slow mode (one message per N seconds) and rate limits.
  • Auto-scroll to the newest message, pause when the reader scrolls up, and offer a clear way to resume.
  • Moderation: deleted messages vanish everywhere, timed-out or banned users cannot post, any viewer can report a message.
  • Extras during the stream: a pinned message, a live poll, and one-tap reactions that float up the screen.

Non-functional requirements

  • The video never stutters because of chat. Chat work stays under about 4 ms of main thread time per frame on a mid-range phone.
  • A new message appears within 1 second of the server accepting it (or within 1 second of the matching video moment, if we delay chat to match the player).
  • Memory stays flat over a 3-hour match: no growth from old messages, emote images, or listeners.
  • Safe: user text never runs as code, and abusive content disappears quickly once moderated.
  • Accessible: a screen reader user can follow the chat at their own pace without being flooded.
  • Resilient: phones switch networks and sleep tabs; the rail reconnects on its own and says so when it is behind.

Out of scope

Video playback itself (covered in the video player and YouTube guides), threaded replies (covered in the comments thread guide), direct messages, paid messages such as Super Chat, and chat replay for recorded matches. We will mention where replay would plug in, because it reuses the same timing idea as spoiler protection.

Check your understanding · Exercise 1

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

What makes live comments hard

Five properties of this problem drive every later decision. Name them early in the interview: they show you see past the list of messages.

1. The rate is far above what a person can read

An adult reads about 200 to 250 words per minute, roughly 4 words per second. A chat message averages 4 to 8 words. So a focused reader takes in about 1 message per second, and a skimmer maybe 3 to 5.

A quiet match already sends 50 messages per second. A goal sends 3,000. At that point the rail is 600 to 3,000 times faster than any human. Delivering every message to every screen is not a feature: it is a cost with no reader.

2. Traffic comes in bursts tied to the match

Load is not smooth. It sits at a baseline for minutes, then jumps to 15 to 60 times that baseline within two seconds when the ball hits the net, and decays over about 30 seconds. Every part of the design must survive the spike, not the average. The spike is also when the viewer cares most.

3. It shares the device with a playing video

The video decoder mostly runs off the main thread, but the player still needs the main thread for its controls, captions, buffering logic, and ads. If chat rendering takes 40 ms in one go, the player misses a frame of work, scrubbing lags, and taps on the composer feel slow. On a phone, battery and heat matter for a 2-hour match.

4. The audience is anonymous and some of it is hostile

Millions of strangers can type into your page. Spam bots, slurs, scam links, and attempts to inject markup arrive every minute. Moderation decisions often land after a message is already on screens, so the client must remove things it already showed.

5. Two clocks: the chat clock and the video clock

Chat travels in about a second. Video travels through encoding, segmenting, and CDN buffering, so it is often 10 to 30 seconds behind the stadium. Two viewers of the same match can be 20 seconds apart. Chat that is faster than the video spoils the moment it exists to celebrate.

Message rates on a log scale, with marks at 1, 10, 100, and 1,000 messages per second. A goal burst sends 3,000 messages per second to the room. A normal match sends 50. The edge sends each viewer 15. A skimming reader takes in 3 to 5 per second, and a focused reader about 1.goal burst3,000 / snormal match50 / ssent per viewer15 / sskimming reader3 to 5 / sfocused reader1 / s1101001,000messages per second, log scale
The scale is logarithmic: each mark on the axis is 10 times the one before. The gap between the room and the reader is more than three orders of magnitude, so most of the design is about what not to show.

Think first

You already designed a chat app like Slack. Which of its guarantees should you drop for live comments, and which must you keep?

Decision 1: what to show when nobody can read it all

Start here, before transport or rendering, because the answer shrinks every later problem. If the client only ever receives 20 messages per second, the socket, the renderer, and the memory budget all get easier.

The naive approach: send everything, render everything

The simplest design pushes every accepted message to every viewer. Do the arithmetic for a goal: 3,000 messages per second times 2 million viewers is 6 billion deliveries per second. At about 300 bytes per message with JSON overhead, that is 900 KB per second per viewer, or roughly 7 Mbit/s: about as much as the 1080p video stream itself.

The phone then parses 3,000 JSON objects per second and tries to render them. The reader sees a blur that scrolls faster than the eye can track. Everyone pays, and nobody gains.

Decision How do we turn 3,000 messages per second into something a person can read?

Client drops what it cannot show

The server sends everything.

The client keeps only the newest N messages from each batch and throws the rest away.

Pros

  • Simple server.
  • Every client can choose its own rate.

Cons

  • Bandwidth and parsing cost stay at full volume, on the device least able to pay.
  • Random dropping can hide important messages, like a moderator warning or a reply to you.

Server samples per connection

The fan-out edge sends each connection at most a target rate, for example 15 messages per second.

When the room is faster, it picks which messages to forward and drops the rest for that connection.

Pros

  • Bandwidth per viewer is capped, whatever the room does.
  • The edge can apply priorities: never drop your own messages, mentions of you, or moderator messages.

Cons

  • Each viewer sees a different slice of the room.
  • The edge must do per-connection work, which costs servers.

Server filters for quality (Top chat)

A ranking step hides likely spam, repeats, and low-quality messages.

YouTube's Top chat works this way: its help pages say it filters messages such as potential spam, while Live chat is unfiltered and shows all messages as they come in.

Pros

  • What remains is more readable, not just less.
  • Also a safety layer.

Cons

  • Needs a classifier, which adds server work and some delay before a message can be sent on.
  • Still too fast after a goal: filtering alone does not cap the rate.

Summarize repeats

When 900 people type GOAL in one second, send one row: GOAL with a count of 900.

Emote spam and reactions become counters, not rows.

Pros

  • Shows the size of the crowd better than 900 identical rows could.
  • Huge cut in volume during exactly the moments that spike.

Cons

  • Needs text normalization on the server (case, repeated letters, emote ids).
  • Merged rows lose the individual authors.

What we pick: Combine them, in this order on the server: moderation first, then Top chat filtering for viewers who chose it, then summarizing repeats, then per-connection sampling to a target rate. The client keeps one safety net: if it falls behind anyway (a slow phone, a backgrounded tab), it drops the oldest unrendered messages rather than rendering stale ones.

How per-connection sampling picks messages

Sampling is not random. Think of each connection as a small bucket that refills at the target rate, say 15 tokens per second, and holds at most 30. Every message spends one token. When the bucket is empty, the edge drops messages for that connection, except the ones that must pass.

Message kindRule at the edgeWhy
The viewer's own messageAlways sent, never sampledThe author must see their message in the stream, or they post it again.
Mentions of the viewerAlways sentSomeone is talking to them.
Moderator, host, pinned, poll eventsAlways sentThese change what the viewer may do or must see.
Delete and ban eventsAlways sent if the target message was sent to this connectionA removed message must disappear everywhere it appeared.
Summary rows (GOAL x 900)High priorityOne row stands for hundreds of messages.
Ordinary messagesSampled to the token budget, spread evenly over the secondEvenly spread looks like a calm crowd; bursts look like lag.

The target rate itself can adapt. The client can tell the server its budget when it connects, for example maxRate=8 on a low-end phone or when the chat panel is collapsed, and maxRate=0 with counters only when the tab is hidden. That turns sampling into a cheap form of back pressure (the receiver telling the sender to slow down).

Check your understanding · Exercise 2

During a goal the edge samples each connection down to 15 messages per second. Which messages must still reach a viewer called mia22? Select all that apply.

Decision 2: how messages reach two million browsers

Traffic in a live rail is lopsided. A viewer receives 5 to 20 messages per second and sends maybe one per minute. So the transport question is really: what is the cheapest reliable way to push a steady stream from server to browser, plus an occasional write the other way? The lessons on websockets, server-sent events, and choosing a realtime transport cover each mechanism; here we only weigh them for this load.

The naive approach: poll every second

Polling means asking "anything new since cursor X?" on a timer. At 1 request per second, 2 million viewers make 2 million requests per second, each paying for headers, TLS work, auth, and a database or cache lookup, mostly to hear "a few more messages". Polling every 5 seconds cuts that 5 times but makes chat feel 2.5 seconds late on average, in bursts of 5 seconds' worth.

Polling is not silly, though. The public YouTube Live Streaming API was built around it: each liveChatMessages.list response carries a pollingIntervalMillis that tells the client how long to wait, so the server controls the load. Google later added a server-streaming method, streamList, to the same API, which resumes from the last page token after a disconnect: the same cursor idea this guide uses. A server-chosen interval is a fine fallback when streaming is blocked.

Decision Which transport carries the stream to the browser?

Polling with a server-chosen interval

GET /chat/messages?after=cursor on a timer.

Each response says when to ask next, from 1 to 10 seconds depending on load.

Pros

  • Plain HTTP: caches, proxies, and auth all just work.
  • The server can slow everyone down during a spike.

Cons

  • Latency is half the interval on average.
  • Every request repeats headers and auth; most of the cost is overhead.

Server-sent events (SSE) plus POST

EventSource opens one long HTTP response; the server writes id: and data: lines as messages arrive.

Sending is a normal POST /chat/messages.

Pros

  • Built-in reconnect that resends Last-Event-ID.
  • Plain HTTP, multiplexed over HTTP/2 or HTTP/3, friendly to edges and CDNs.
  • Writes get normal HTTP status codes for errors like 429.

Cons

  • Text only, one direction.
  • EventSource cannot set custom headers, so auth uses a cookie or a short-lived token in the URL.
  • On HTTP/1.1 it counts against the 6 connections per host limit.

WebSocket

One upgraded TCP connection carries frames both ways.

Twitch documents its chat interface this way: IRC commands over a WebSocket at wss://irc-ws.chat.twitch.tv.

Pros

  • Both directions on one connection; binary frames are possible.
  • Lowest overhead per message.

Cons

  • You build reconnect, resume, and heartbeats yourself.
  • Some corporate proxies break it; load balancers need care with long-lived upgraded connections.

What we pick: Use one push stream for reading and plain HTTP for writing. This guide picks SSE for the stream, because its reconnect and cursor resume are built in and it fits HTTP edges well. A WebSocket is an equally good answer if the team already runs WebSocket infrastructure (Twitch's chat interface is IRC over a WebSocket); just say you will add resume and heartbeats. Keep polling with a server-chosen interval as the fallback when the stream fails to connect twice.

Fan-out: one message, two million sockets

No single server holds 2 million connections. One edge node can hold somewhere around 50,000 to 200,000 mostly idle streaming connections, depending on hardware and tuning, so a big match spreads over 10 to 40 edge nodes in several regions. The chat service does not talk to each socket. It publishes each accepted message once to a pub/sub topic for the room (pub/sub: publishers send to a named topic; every subscriber receives a copy).

Each edge node subscribes to the topic once, receives the message once, and then copies it to its local connections, applying per-connection sampling on the way. So the expensive copy happens in memory, close to the viewer.

Fan-out for one room. A viewer's composer sends a POST to the chat service, which moderates the message and gives it a sequence number. The chat service publishes it once to the room topic in pub/sub. Three edge nodes, EU, US, and Asia, each subscribe once and receive one copy. Each edge samples per connection, keeps a ring of recent events, and writes SSE to its 50,000 to 200,000 local streams.ComposerPOSTChat servicemoderate, orderPub/subroom topicEdge EUsample, ringEdge USsample, ringEdge Asiasample, ringone copy per edgeeach edge: 50k to 200kSSE streams, sampledSSE to viewers
The expensive copy happens in edge memory, close to viewers. The chat service sends one message per room, not one per viewer.

Reconnecting with a cursor, and why gaps are fine

Every event gets a monotonically increasing id per room, like 98231. The browser remembers the last id it processed. When the connection drops, EventSource reconnects and sends Last-Event-ID: 98231 by itself.

Each edge node keeps a small ring of recent events per room, for example the last 60 seconds. If the cursor is inside the ring, the edge replays what was missed (sampled, like everything else). If it is older, the edge sends a reset event with the newest 50 messages, and the client replaces its list. A short gap in a crowd's cheering is invisible; a 30-second replay of 90,000 messages would be useless and expensive.

The exception is the viewer's own message. It never depends on the stream: the client keeps it in a local outbox until the POST returns, and the response says what happened to it. Chapter 6 covers that path.

The connection state machine. Connecting goes to Live when the stream opens. Live goes to Reconnecting when the stream drops, and back to Live when it resumes with Last-Event-ID. After 2 failed reconnects, Reconnecting goes to Polling at a server-chosen interval, which returns to Live when the stream works again. From Live, losing the network goes to Offline, and coming back online goes to Connecting.Connectingfirst openLiveLast-Event-IDReconnectingjittered retryPollingserver intervalOfflinecomposer offopendropresume2 failuresstream worksofflineonlineany reconnect may get a reset snapshot
Every reconnect may end in a reset snapshot instead of a replay. Only Offline disables the composer; the other states keep chat usable.

The user can see the connection state. While reconnecting, the rail shows a thin "Reconnecting..." bar and keeps the old messages; after a reset, the crowd simply continues with nothing special shown. In polling, chat still works, a little slower. Only offline (the browser reports no network) disables the composer.

Check your understanding · Exercise 3

A viewer's phone switches from Wi-Fi to cellular and the stream is gone for 45 seconds. The edge keeps a 60-second ring. What should happen? Select all that apply.

Decision 3: rendering a fast stream next to a video

Sampling caps what arrives at roughly 15 messages per second in normal play, but the client must still survive more: a replay after reconnect, a slow phone that falls behind, or a server that samples less than we hoped. The renderer has to be cheap at any input rate.

The naive approach: one state update per message

The obvious React code calls setMessages(prev => [...prev, msg]) in the onmessage handler. Each call can cause a render, a DOM insert, a style recalculation, a layout, and a scroll. On a mid-range phone we estimate about 1 to 3 ms per message once the list is long (a rough figure; measure on your own target devices).

At 15 messages per second that is fine. At 300 per second (a replay burst) it is 300 to 900 ms of work per second: the main thread is busy most of the time, taps on the player lag, and the composer drops keystrokes. The array copy also grows with the list, and with no limit the list grows for 3 hours.

React 18 and later batch state updates automatically, but only the ones that land before React starts rendering. Each onmessage is its own task, so how many messages share a render depends on timing you do not control. Make the batching explicit.

Decision How often should incoming messages reach the screen?

Render each message on arrival

Every event updates state immediately.

Pros

  • Lowest possible latency.
  • Simplest code.

Cons

  • Cost grows with traffic; it collapses during bursts.
  • Text that moves 50 times a second cannot be read.

Flush once per animation frame

Push events into a plain array (not state).

A requestAnimationFrame callback moves the whole buffer into state once per frame.

Pros

  • At most 60 commits per second whatever the input rate.
  • rAF stops in hidden tabs, so background tabs do no render work.

Cons

  • Still up to 60 layouts per second during busy periods.
  • The list still jitters at 60 Hz, which is hard to read.

Flush on a fixed tick (100 to 250 ms), aligned to a frame

Buffer events; every 150 ms schedule one rAF flush.

Each flush appends at most K messages, the newest ones, and drops the rest if the buffer overflowed.

Pros

  • 4 to 10 commits per second: cost depends on time, not traffic.
  • The list moves in small steps that the eye can follow.
  • Combines naturally with dropping when behind.

Cons

  • Adds up to 150 ms of latency, which nobody notices in a crowd chat.
  • Needs care so the tick stops when the tab is hidden.

What we pick: Flush on a 150 ms tick, run inside requestAnimationFrame so the DOM work lands right before a paint. Cap each flush at 20 messages, keeping the newest. Then cap the list itself at 200 rows. That makes the worst case fixed: about 7 commits per second, each inserting at most 20 rows into a list of at most 200.

Half a second of chat rendering. With one render per message, 15 messages cause 15 commits at irregular times, each with its own layout and scroll. With a 150 ms tick, the same messages cause 3 commits, at 150, 300, and 450 ms, holding 5 messages each.each: 15 commitstick: 3 commits5550 ms250 ms500 ms
Batching makes render work depend on time, not traffic. At 3,000 messages per second the top row would be solid; the bottom row would still be 3 commits.
ts
const FLUSH_MS = 150, MAX_PER_FLUSH = 20, CAP = 200;
let buffer: ChatMessage[] = [];
let scheduled = false;

export function onIncoming(msg: ChatMessage) {
  buffer.push(msg);
  if (buffer.length > 500) buffer = buffer.slice(-MAX_PER_FLUSH); // far behind: skip ahead
  if (!scheduled) {
    scheduled = true;
    setTimeout(() => requestAnimationFrame(flush), FLUSH_MS);
  }
}

function flush() {
  scheduled = false;
  const batch = buffer.slice(-MAX_PER_FLUSH);
  buffer = [];
  store.append(batch, CAP); // one state update, trims the oldest beyond CAP
}
The buffer lives outside React. Only the flush touches state.

Capping the list, and why we do not virtualize

When following the live edge, the list keeps the newest 200 messages. Each flush appends new rows and removes the same number of oldest rows from the top. The DOM size, memory, and layout cost all stay flat for the whole match.

Virtualization (mounting only the rows near the viewport) is the usual answer for long lists, so why not here? Because the list is not long. 200 short rows is about 1,000 DOM nodes, and appending a few rows to it costs around a millisecond of layout on a mid-range phone, by our rough estimate.

Virtualization would add measuring of variable-height rows, scroll anchoring at the bottom, and blank flashes during fast appends, to solve a problem we removed by capping. If the product later wants hours of scrollback, that is the moment to add it.

Keyed, append-only rows

  • Key each row by the server message id, never by index. With index keys, trimming one row from the top shifts every key and React rerenders all 200 rows.
  • Wrap the row in memo and pass only that message. A flush then renders the new rows and nothing else.
  • Treat messages as immutable. A delete or an edit replaces the object for that id, so memo notices exactly that row.
  • Give rows contain: layout paint (CSS containment), so layout and paint work caused inside one row stays inside that row instead of spreading to the rest of the list.

Avoiding layout thrash

Layout thrash is when code alternates writes to the DOM with reads of layout values like scrollHeight or getBoundingClientRect(), forcing the browser to compute layout again and again in one frame. Auto-scroll code is the classic culprit: append a row, read scrollHeight, scroll, append another row, read again.

The fix is to stop interleaving: at most one layout read and one write per flush. Commit the batch, then set scrollTop once in a layout effect. The next chapter removes even the read, by tracking whether the reader is following with a flag instead of measuring. The runtime performance lesson covers forced synchronous layout in depth.

The lab below plays a match against these choices. Its costs come from a rough model of a mid-range phone (about 1 ms per commit, 0.1 ms per inserted row, and layout that grows with the rows on the page), so read the numbers as estimates and the trends as the point.

Room rate
Render
The message rate lab before playing. Nothing has arrived yet.chat at 0 sno messages yetarrived: 0 in 1 sshown 0, skipped 0commits: 0 in 1 sbusy: 0 ms of 40 ms budgetlongest: 0 ms of 4 msrows on page: 0network: 0 KB/sone frame at 60 Hz: 16.7 msrough costs for a mid-range phone, not measurements
Pick a rate and a strategy, then play a second of the match.

Check your understanding · Exercise 4

A teammate proposes rendering every message as it arrives and adding a virtualized list to keep it fast. What is wrong with that plan? Select all that apply.

Decision 4: auto-scroll that stops when someone reads

A live rail follows the newest message by default. That is right until a viewer spots something funny and scrolls up to read it. If the list keeps scrolling, or keeps trimming rows from the top, the message they wanted slides away under their finger.

Big platforms make the pause visible. Twitch shows a bar along the lines of "Chat paused due to scroll", and YouTube shows a button that jumps back to the newest messages; exact labels change between app versions. Our rule is close to the one the LLM chat UI guide uses for streaming answers, with two twists for a fast crowd.

The naive approach: scroll if near the bottom

The common snippet checks scrollHeight - scrollTop - clientHeight before each update and scrolls down if it is under 40 px. In a live rail this fails in two ways. A flush of 20 tall rows (long names, two lines of emotes) can push the reader past the threshold in one step, so the chat stops following by itself. And the check reads layout on every flush, the kind of read the previous chapter tried to keep to a minimum.

Decision How does the rail decide whether to follow the newest message?

Distance from the bottom

Before each flush, measure the distance to the bottom; follow if it is under a threshold.

Pros

  • Ten lines of code.

Cons

  • Large flushes turn following off by mistake.
  • A layout read per flush.

A follow flag driven by user intent

following turns false only on user input that scrolls up: wheel with negative deltaY, a touch drag downward, Page Up, Up arrow, Home, or a scrollbar drag.

It turns true when the reader reaches the bottom or presses the resume button.

Pros

  • Content growth can never pause the chat.
  • Our own programmatic scrolls never change the flag.

Cons

  • Several listeners; scrollbar drags arrive only as scroll events and need a check.

Pause on hover

Freeze the list while the pointer is over it, an option some chat clients offer as a setting (Twitch has a pause on mouseover choice).

Pros

  • Lets a desktop reader stop the flow without scrolling.

Cons

  • Useless on touch screens.
  • Surprising as a default: moving the mouse across the page stops the chat.

What we pick: Use the intent flag, with an IntersectionObserver sentinel at the end of the list to detect "back at the bottom" without layout reads, as in the LLM chat UI guide. Offer pause on hover only as an opt-in setting for desktop.

The two twists: freeze and count

First twist: while paused, nothing is trimmed. Following mode removes the oldest rows on every flush; if that kept happening while someone reads, the rows above them would disappear and the content would jump. So paused mode freezes the visible list. New messages go into a waiting buffer instead, capped at 500, newest kept.

Second twist: say what is happening. A paused rail shows a bar at the bottom, "Chat paused: 37 new messages", and the bar is a button. Pressing it (or scrolling to the bottom) appends the newest 200 waiting messages in one commit, trims back to the cap, scrolls instantly to the end, and turns following back on. The count updates at most twice a second, so it does not flicker.

Moderation still applies to a frozen list. If a message the reader is looking at is deleted, its row is replaced in place with "Message removed by a moderator". Pausing must never become a way to keep reading something that was taken down.

Auto-scroll as two states. Following: new rows are shown, the list is trimmed to 200 rows, and it scrolls to the end. A user scroll up, an Up key, or focus moving into the list switches to Paused: new rows wait in a buffer of up to 500, nothing is trimmed, and a bar says 37 new. Reaching the bottom, pressing the bar, or sending a message switches back to Following.pause on intent, resume at the bottomFollowingauto-scroll onPausedlist frozenuser scrolls upbottom, bar, sendnew rows: showntrim to 200 rowsscroll to the endnew rows: waittrim: nonebar: 37 new
Intent pauses; the bottom sentinel, the bar, or the viewer's own send resumes. Moderation removals still apply while paused.
ts
const UP_KEYS = new Set(["PageUp", "ArrowUp", "Home"]);

list.addEventListener("wheel", (e) => { if (e.deltaY < 0) pause(); }, { passive: true });
list.addEventListener("keydown", (e) => { if (UP_KEYS.has(e.key)) pause(); });
list.addEventListener("touchmove", onTouchMove, { passive: true }); // finger moving down = content up

const io = new IntersectionObserver(([entry]) => {
  if (entry.isIntersecting && !store.following) resume();
}, { root: list });
io.observe(sentinel); // a 1 px element after the last row

function resume() {
  store.mergeWaiting(200); // one commit, trims to the cap
  store.setFollowing(true);
}

// After each commit, inside a layout effect:
if (store.following) list.scrollTop = list.scrollHeight; // instant, not smooth
Intent decides pausing; the sentinel decides resuming.
  • Scroll instantly while following. Smooth scrolling 7 times a second never finishes one animation before the next one starts.
  • Set overflow-anchor: none on the list, so the browser's scroll anchoring does not fight our bottom pinning when rows are trimmed from the top.
  • Trimming the top while following changes scrollHeight. Because we set scrollTop to the bottom right after, the reader sees no jump.
  • When the viewer sends a message, resume following, so they see their own message land.
Rail
The chat rail is following the newest messages. Visible rows: #7 jojo: replay pls; #8 ria: what a save; #9 sam_k: ref, come on; #10 devo: keeper again; #11 mia22: offside?; #12 ozzy: unreal pass. 12 rows in memory, cap 12.chat: following#7 jojo: replay pls#8 ria: what a save#9 sam_k: ref, come on#10 devo: keeper again#11 mia22: offside?#12 ozzy: unreal passfollowing: onin memory: 12waiting: 012 rows stand in for 200
Following: new messages appear at the bottom and the oldest are trimmed.

Check your understanding · Exercise 5

A viewer scrolls up to reread a funny message. 40 seconds later, they press "Chat paused: 500+ new messages". What should happen? Select all that apply.

Decision 5: sending a message under slow mode and moderation

Sending looks trivial next to receiving: one POST. But it is the one path where the user cares about every single message, and the server has several reasons to say no: slow mode, rate limits, duplicates, blocked words, a timeout or ban, or a moderator holding it for review. Some of those answers come back in 50 ms, and some come back seconds later.

The naive approach: wait for the stream

The simplest client posts the message, clears the box, and waits for it to come back through the stream. During a goal, the server pipeline (moderation, ordering, fan-out) can take 1 to 3 seconds. The viewer sees nothing, assumes it failed, and types it again, which trips the duplicate rule. If the message was rejected, nothing ever comes back and the viewer never learns why.

Decision How does the viewer's own message appear?

Wait for the stream copy

Show nothing until the message arrives through the stream.

Pros

  • The screen only ever shows accepted messages.

Cons

  • 1 to 3 seconds of silence at peak.
  • Rejections are invisible.

Optimistic local echo

Insert the message at the bottom at once, marked pending, with a client id.

The POST response or a later event moves it to sent, held, or rejected.

Pros

  • Instant feedback in the place the user is looking.
  • Every outcome has a visible state.

Cons

  • Needs dedupe when the stream copy arrives.
  • The echo's position may differ slightly from where others see it.

Echo in the composer only

Keep the text in a "sending..." line under the input until accepted.

Pros

  • The list only holds accepted messages.

Cons

  • Feels slower and splits the user's attention between two places.

What we pick: Optimistic local echo, with the outcome always shown on the echo itself. The pattern is the usual optimistic update; the live twists are slow mode, late moderation, and sampling.

The flow, step by step

  1. On Enter, the composer runs cheap local checks: not empty, at most 200 characters, not identical to the viewer's last message in the past 30 seconds, and the slow mode countdown is at zero.
  2. It creates clientId = crypto.randomUUID(), appends a pending echo to the list, resumes following, clears the box, and starts the slow mode countdown.
  3. It sends POST /rooms/:id/messages with the text and clientId. The server uses clientId as an idempotency key, so a retry after a timeout cannot post twice.
  4. The server replies 201 with the server id and sequence number: the echo becomes sent. Or it replies 202 with held: the echo shows "Held for review, only you can see this". Or 4xx with a reason: the echo shows the reason and offers edit and resend where that makes sense.
  5. When the stream later delivers a message whose clientId matches an echo, the client drops the stream copy and keeps the echo where it is. The server always sends the viewer's own messages, so sampling never hides them.
  6. If a moderator removes the message later, a delete event replaces the echo's text, just like any other row.
Sending as a sequence diagram between the composer, the chat store, the chat API, and the edge stream. The composer adds a pending echo to the store, then sends POST with a clientId. The API replies 201 sent and the composer marks the echo sent. Later the edge stream delivers the server copy with the same clientId, and the store drops it. Failure path: the composer sends the next message too soon, the API replies 429 wait 7 seconds, and the echo becomes not sent with a retry button.ComposerChat storeChat APIEdge streamecho: pendingPOST + clientId201 sentmark sentcopy: droppedfailure pathPOST next msg429: wait 7secho: not sent
The echo appears before any network call, and every server answer lands on it. The stream copy is matched by clientId and dropped, so the message never shows twice.

Slow mode and rate limits

Slow mode lets a channel allow one message per N seconds per viewer; both YouTube and Twitch offer it. The room state sent on connect includes slowModeSeconds, and a room-settings event updates it live. The composer shows a countdown on the send button ("Wait 7s") and keeps the text box enabled, so the viewer can type the next message while waiting.

The countdown is a courtesy; the server enforces the rule. If the client and server disagree (another tab sent a message, or the clock drifted), the server answers 429 Too Many Requests with a Retry-After header or a retryAfterMs field, and the client resets the countdown from that. Never trust the client clock to grant permission.

Rate limits sit under slow mode and apply even when it is off. Twitch, for example, documents limits for chat bots of 20 messages per 30 seconds for a regular account and 100 per 30 seconds for the broadcaster, moderators, and VIPs. Your numbers depend on the product, but the client handles every limit the same way: the server decides, and the echo shows the answer.

Server answerEcho showsComposer does
201 acceptedNormal messageNothing more
202 held for reviewDimmed, "Held for review, only you can see this"Nothing more
429 slow mode or rate limit"Not sent: wait 7s" with a resend buttonRestores the text, restarts the countdown
409 duplicate"Not sent: you just said that"Restores the text so it can be changed
422 blocked content"Not sent: this message breaks the chat rules"Restores the text
403 timed out or banned"You are timed out for 5 minutes"Disables the box with the remaining time
Network error or timeout"Not sent" with retryRetries once with the same clientId, then waits for the user
The states of the viewer's own message. Pending, with the echo shown, goes to Sent on 201, to Held on 202 where only the author sees it, to Rejected on 429, 409, or 422, or to Failed on a network error. Failed goes back to Pending on a retry with the same clientId. Held goes to Sent if a moderator approves it. Sent goes to Removed if a moderator deletes it. A rejected message puts its text back in the box.Pendingecho shownSent201Held202, only youRejected429, 409, 422FailednetworkRemovedmoderatortimeoutretryapproveddeleterejected: text goesback in the box
Every state is visible on the echo. Only Failed retries by itself, once, with the same id.

Check your understanding · Exercise 6

A viewer sends "what a save" during a goal. The POST times out after 8 seconds. What should the client do? Select all that apply.

Decision 6: moderation and safe rendering

Most moderation happens on the server, but the client decides how fast removals show up, whether user text can ever become code, and how a viewer reports abuse. Getting those wrong turns a moderation team's work into a race the client loses.

Where each check runs

CheckClientServer
Blocked wordsA small hint list warns before sending: "This may not be posted".The real list, with normalization (case, spacing, look-alike letters), decides.
LinksNot turned into links for normal users.Rejected or stripped for non-trusted users.
Spam and repeatsLocal duplicate check, for feedback only.Rate limits, duplicate rules, spam classifiers, and Top chat filtering.
Hold for reviewShows the held state on the author's echo.Sends risky messages to a review queue instead of fan-out.
Delete, timeout, banApplies events: removes rows, disables the composer.Moderators act; the service emits events into the ordered stream.

The client list is a hint, never a gate. It ships in the bundle, so anyone can read and route around it. Its only job is to save an honest user a round trip.

Deleting messages that are already on screen

A slur passes the automatic filter, appears on 2 million screens, and a moderator deletes it 4 seconds later. The service emits { type: "delete", id: "m_98123" } into the same ordered stream. Because it travels in the same stream with a higher sequence number, it can never arrive before the message it removes.

The client looks the id up in its message map. If the row exists, it replaces the message object with a tombstone (a placeholder that says the item was removed) so the row shows "Message removed by a moderator" and the layout barely moves. If it does not exist, the client remembers the id for a minute in a small set, in case the message arrives late through a replay.

Two bigger events follow the same idea. purge-user removes every visible message from one author after a timeout or ban; the client scans its 200 rows, which takes microseconds. clear-chat empties the list. The viewer who was banned also receives you-are-banned or you-are-timed-out with an end time, and the composer turns into a notice with a countdown.

Moderation as a sequence diagram between a moderator, the chat service, the edge, and the viewer's rail. The chat service sends message m_98123 to the edge, which sends it to the rail, where it renders. The moderator sends delete m_98123. The chat service sends a delete event with sequence 98140 to the edge, which sends delete m_98123 to the rail, which replaces the row with a tombstone. The moderator then times out user u_77 for 5 minutes, and a purge-user u_77 event follows the same path.ModeratorChat serviceEdgeViewer railmessage m_98123m_98123delete m_98123delete, seq 98140deleterow: tombstonetimeout u_77purge-user u_77purge
Removals ride the same ordered stream as messages, so a delete can never overtake the message it removes. The rail finds the row by id and swaps in a tombstone.

Reporting

Every row has a menu with Report. The report sends the message id and a reason, not the text: the server already has the text, and the client copy could be stale. After reporting, the client can hide that message for the reporter at once, and offer to block the author, which hides their future messages locally and on the viewer's other devices.

Rendering user text safely

Chat text comes from strangers, so treat it as hostile. The xss lesson covers the attack in full; for this rail the rules are short.

  • Never build rows with innerHTML. Render text as text nodes: in React, a string child is escaped by default.
  • Do not parse markdown or HTML in messages. The only rich parts are emotes and mentions, and those arrive from the server as structured segments, not markup.
  • Emote images use URLs from the server's emote catalog only, never from message text.
  • Strip or neutralize bidirectional control characters and long runs of combining marks (the "Zalgo" text that draws over neighboring rows), and cap rows at two or three lines with overflow: hidden as a final guard.
  • Back it up with a Content Security Policy that blocks inline scripts, so a missed escape cannot run code.
tsx
type Segment =
  | { kind: "text"; text: string }
  | { kind: "emote"; id: string; name: string }
  | { kind: "mention"; userId: string; name: string };

function MessageBody({ segments, me }: { segments: Segment[]; me: string }) {
  return segments.map((s, i) =>
    s.kind === "text" ? <span key={i}>{s.text}</span>
    : s.kind === "emote" ? <Emote key={i} id={s.id} name={s.name} />
    : <span key={i} className={s.userId === me ? "mention-me" : "mention"}>@{s.name}</span>,
  );
}
A message is a list of typed segments, so the renderer never interprets text.

Check your understanding · Exercise 7

A moderator deletes a message 4 seconds after it appeared. Which client behaviors are right? Select all that apply.

Rich features: emotes, mentions, pins, polls, and reactions

The extras are what make a live rail feel like a stadium. Each one is small, but each has a way to blow the performance budget if built naively. The rule for all of them: the server aggregates, the client draws a bounded amount.

Emotes: images with a catalog and a cache

An emote is a small image that stands for a code, like :goal: or a channel's custom celebration face. The server sends a catalog when the viewer joins: global emotes plus the channel's own, each with an id, a name, and image URLs at 1x and 2x. A busy channel may have 300 emotes; the catalog is about 30 KB of JSON and is cached with ETag for the next visit.

  • The server parses messages into segments, so the client never guesses which words are emotes. It just looks up the id in the catalog map.
  • Every emote img has fixed width and height (for example 28 by 28) so rows never shift when the image loads, plus decoding="async" and a srcset for 2x screens.
  • Image URLs are versioned and served with Cache-Control: max-age=31536000, immutable. The same emote shown 500 times in a match is fetched and decoded once.
  • loading="lazy" helps for the emote picker, which may hold hundreds of images below the fold. In the rail itself almost every row is near the viewport, so it matters less.
  • Animated emotes are the expensive kind: each one keeps decoding frames. Ask the server for a static variant when prefers-reduced-motion: reduce is set, and cap animated emotes per row.

Mentions

A mention arrives as a segment with a user id. The client highlights rows that mention the current viewer with a tinted background and a left border, so it works without color alone. The edge never samples them away (chapter 3). The composer offers name completion after @ from the authors of the last 200 messages, which needs no extra request.

Pinned messages and polls

A pinned message is room state, not a row. A pin event carries the message, and the rail shows it in a collapsible bar above the list, outside the scrolling area, so trimming never removes it. An unpin event clears it.

A poll works the same way: a poll-open event with the question and choices, a vote sent as POST /polls/:id/votes, and poll-results events with totals. The server sends totals at most once per second, however many votes arrive. The client shows the viewer's own vote at once and animates bars toward the new totals.

Reaction bursts

One-tap reactions (a heart, a ball, a fire icon) float up from the corner of the player, as Facebook Live popularized. They are not messages. Sending each tap as a message would multiply load during exactly the moments that spike.

  1. A tap shows a floating icon locally at once, so the button feels alive.
  2. The client counts taps and sends one POST /rooms/:id/reactions with counts per type every 2 seconds at most, and caps a single viewer at 10 per second.
  3. The server sums all viewers per second and broadcasts { type: "reactions", counts: { heart: 18400, fire: 6100 } }.
  4. The client maps big counts to a small number of icons, for example min(12, 2 + log10(count) * 2) per second, so 18,400 hearts look like a storm without spawning 18,400 elements.
  5. Icons animate with transform and opacity only, which the compositor can run off the main thread, and are removed on animationend. A pool of at most 30 elements is reused.
  6. With prefers-reduced-motion: reduce, no floating: a counter beside the button ticks up instead.
Reactions as a sequence diagram between the viewer, the reaction batcher, the API, and the edge. The viewer taps heart 6 times; the batcher floats icons locally at once. Every 2 seconds the batcher sends the counts to the API, which sums all viewers and sends room totals to the edge. The edge sends heart 18,400 to the batcher, which draws 10 pooled icons. Failure path: the API answers 429, and the batcher keeps the local animation and drops the counts.ViewerBatcherAPIEdgetap heart x6float nowcounts, every 2 sroom totalsheart: 18,40010 icons429: drop counts
The tap feels instant because the first icon is local. The crowd's size arrives as a number and is drawn on a log scale with a fixed pool of elements.

Check your understanding · Exercise 8

After a goal, the server reports 50,000 heart reactions in one second. How should the client draw it? Select all that apply.

Decision 7: keeping chat in time with the video

Picture the 78th minute. The striker shoots at 20:31:00 stadium time. Chat travels in about a second, so viewers watching at low latency type GOAL and it reaches everyone at 20:31:03.

Mia's player is on normal HLS latency, 22 seconds behind. Her chat explodes with GOAL while the ball is still in midfield on her screen. The rail spoiled the one moment it exists for.

Why are players behind at all? Live video is encoded, cut into segments of 2 to 6 seconds, published in a playlist, and buffered by the player for safety, usually a few segments. That adds up to 10 to 30 seconds for standard HLS or DASH, and 2 to 5 seconds for low-latency modes. The video player guide explains the trade-off; for us the point is that the gap differs per viewer and changes during the match.

Decision How do we stop chat from running ahead of the viewer's video?

Do nothing

Show messages as they arrive.

Pros

  • Simplest. Lowest chat latency.

Cons

  • Spoils every big moment for viewers on normal latency, which is most of them.

One fixed delay for everyone

The server holds all chat for, say, 15 seconds before fan-out.

Pros

  • No client work.
  • Also gives moderators time to remove messages before anyone sees them. Twitch's Chat Delay setting is built for this: it holds chat for a few seconds (2, 4, or 6 when it launched) so moderators get a head start.

Cons

  • Wrong for everyone who is not exactly 15 seconds behind: low-latency viewers see chat 12 seconds late, others still get spoiled.

Per-viewer delay matched to the player

Every message carries the server time it was accepted.

The player reports the wall-clock time of the frame on screen.

The client releases a message only when the video has reached its time.

Pros

  • Each viewer's chat matches their own picture, whatever their latency.
  • Works the same when latency drifts during the match.

Cons

  • Needs a wall-clock time from the player, which needs the stream to carry it.
  • Messages wait in memory for up to 30 seconds.

What we pick: Per-viewer delay, on by default for sports and switchable in the chat menu ("Sync chat to video"). We could not find a major platform that documents doing this for live chat, so treat it as a design proposal, not industry standard. It is the same idea that chat replay for recorded streams already uses: show each message at the video time it was posted.

How the client knows what time is on screen

HLS playlists can carry EXT-X-PROGRAM-DATE-TIME tags, which tie the first frame of a segment to an absolute date and time, usually set when the video is captured or encoded. The popular hls.js player exposes hls.playingDate, the date of the frame at video.currentTime, and hls.latency, the distance to the live edge. DASH can give the same mapping through the manifest's availabilityStartTime and its timing elements.

Both clocks belong to servers: the encoder stamps the video, and the chat service stamps messages. If both keep their clocks in sync (for example with NTP), the client compares two server times and never needs its own clock, which may be off by seconds or more on a phone.

ts
const GRACE_MS = 500; // release a moment after the matching frame, never before
const waiting: ChatMessage[] = []; // arrives in server order, so already sorted by ts

export function onStreamMessage(msg: ChatMessage) {
  if (msg.authorId === me.id || !settings.syncToVideo) return toBuffer(msg); // toBuffer is onIncoming from the flush code
  waiting.push(msg);
}

export function releaseDue(player: { playingDate: Date | null }) {
  const onScreen = player.playingDate?.getTime();
  if (onScreen === undefined) return releaseAll(); // no wall clock: fall back to live
  while (waiting.length && waiting[0].ts + GRACE_MS <= onScreen) {
    toBuffer(waiting.shift()!);
  }
}
A FIFO delay queue drained by the same 150 ms flush tick. Moderation events never enter it: the stream client applies them at once.
A timeline from 20:30:55 to 20:31:30. The shot happens in the stadium at 20:31:00. The chat server accepts GOAL at 20:31:03. Without sync, Mia's chat shows GOAL at 20:31:03, but her video is 22 seconds behind and shows the shot at 20:31:22, so chat spoils it. With sync, her chat shows GOAL at 20:31:25, when her player's playingDate reaches 20:31:03.stadiumshotchat serverGOALchat, no syncGOALMia's videoshotchat, syncedGOALspoils the goal22 s behind live20:31:0020:31:22
The client releases each message when the player's playingDate reaches the message's server time. Chat then follows Mia's picture, not the stadium.

Edge cases

  • Own messages skip the queue. The viewer is reacting to what they see, so their message belongs at the bottom now. Mentions of them wait like any other message.
  • Moderation events skip the queue. A delete must apply at once; if the message is still waiting, it is removed from the queue and never shown.
  • The viewer pauses or rewinds. The queue grows. If the viewer is more than 60 seconds behind live, stop queueing and show a notice: "You are 4 minutes behind live. Chat is showing live messages." with a button to jump the video to live.
  • Room events like polls and pins are tied to the broadcast, so they go through the queue too: a poll about the penalty should not open before the penalty is given on screen.
  • Memory. At 15 messages per second and 30 seconds of delay, the queue holds 450 small objects, about 200 KB. Cap it at 1,000 and drop the oldest if a broken player stops reporting time.

Check your understanding · Exercise 9

Mia's player is 22 seconds behind live and chat sync is on. Which statements are true? Select all that apply.

Architecture, data model, and interface

With the decisions made, put the client together. The guiding rule is that the network and timing work lives outside React, and React only sees small, already-batched state changes. Components subscribe to exactly the slice they draw.

The client architecture inside the browser, in three rows. Top row, outside React: the stream client with SSE and a cursor feeds the sync gate, which feeds the flush tick every 150 ms inside requestAnimationFrame, which also updates the room store with slow mode and the pin. Middle row: the caches for emotes and users, the player, which gives the sync gate its playingDate, the chat store with 200 rows and a map by id, filled by the flush tick, and the outbox with client ids, which adds echoes to the chat store and sends POST requests. Bottom row, React components: MessageList, fed by the caches and the chat store, PausedBar, fed by the chat store, Reactions, and Composer, which hands messages to the outbox.BrowserStream clientSSE, cursorSync gatevideo clockFlush tick150 ms, rAFRoom storeslow mode, pinCachesemotes, usersPlayerplayingDateChat store200 rows, mapOutboxclientId, POSTMessageListPausedBarReactionsComposerReact reads stores with useSyncExternalStore
Network and timing work stays in plain modules on the top two rows. React components on the bottom row subscribe to store slices with useSyncExternalStore, so a flush re-renders only the list.
PieceResponsibilityWhy it is its own piece
Stream clientOwns the EventSource, the last event id, jittered backoff, and the polling fallback. Parses events and hands them on.Transport can change (SSE, WebSocket, polling) without touching anything else.
Sync gateHolds messages until the player's on-screen time reaches them. Passes own messages and moderation events straight through.Only place that knows about the video clock.
Flush schedulerBuffers events and applies them once per 150 ms tick inside requestAnimationFrame, keeping the newest 20.Turns any input rate into a fixed render rate.
Chat storeRing buffer of the last 200 messages, a map by id, the follow flag, and the waiting buffer while paused.One source of truth for rows; mutations are cheap and immutable per message.
Room storeSlow mode seconds, pinned message, open poll, viewer status (ok, timed out, banned), connection state.Changes rarely; many small components read it.
Catalog cachesEmote catalog by id, user badges by id.Shared by rows; fetched once per channel.
OutboxPending own messages by clientId, the POST and retry logic, the slow mode countdown shared over BroadcastChannel.The only path that must be reliable.
Reaction batcherCounts taps, sends every 2 seconds, turns server counts into a capped number of floating icons.Keeps a high-frequency gesture off the message path.
UI componentsChatPanel, PinnedBar, PollCard, MessageList, MessageRow, PausedBar, Composer, ReactionLayer.Each subscribes to its slice only.

React reads the stores through useSyncExternalStore. MessageList subscribes to the row array and the follow flag; a MessageRow gets one immutable message as a prop and is memoized; the Composer keeps its text in local state, so typing never re-renders the list, and the list flushing never re-renders the composer.

Data flow end to end

Follow one message from a stranger's phone to Mia's screen, and Mia's own message back. Each arrow in the next figure is labelled with what crosses it.

End-to-end data flow. Top row: Mia's composer sends a POST with a clientId to the chat service, which publishes once to pub/sub, which sends one copy to each edge. The edge sends sampled SSE events down to Mia's stream client. Middle row, right to left: the stream client passes parsed events to the sync gate, which passes events whose time has come to the flush tick, which commits a batch of up to 20 to the chat store. The composer also puts an echo straight into the chat store. Bottom: the chat store gives rows to the MessageList. Mia's own copy in the stream is matched by clientId and dropped.ServersComposerMia, POSTChat servicemoderatePub/subroom topicEdgesample, ringpublish once1 copySSE, sampledStream clientparseSync gateon timeFlush tick150 msChat store200 rowseventsdue nowbatch of 20echoMessageListrowsMia's own copy in the streamis matched by clientId, dropped
Reading flows down the right side and back along the middle; writing goes across the top. The two meet in the chat store, where clientId joins the echo with its copy from the stream.
  1. A viewer posts. The chat service runs moderation, assigns a room sequence number and timestamp, and publishes once to the room topic.
  2. Each edge node receives it once, summarizes repeats, samples per connection, and writes an SSE event to each of its local streams.
  3. Mia's stream client parses the event and passes it to the sync gate, which holds it until her player reaches its timestamp.
  4. The flush scheduler takes it on the next tick, together with up to 19 others, and commits one batch to the chat store.
  5. MessageList renders the new rows; a layout effect scrolls to the bottom if following.
  6. When Mia sends, the outbox adds a pending row straight to the chat store, POSTs it, and updates the row from the response. Her copy in the stream is matched by clientId and dropped.

Think first

Should the stream client and the sync gate run in a Web Worker to keep the main thread free for the video?

Check your understanding · Exercise 10

Why does the composer keep its draft text in local component state instead of the chat store? Select all that apply.

Data model: a ring buffer, not a history

A chat app's data model is a normalized cache of conversations that can grow for years. A live rail's model is the opposite: a fixed-size window that forgets on purpose. Memory must look the same at minute 1 and minute 180.

EntityWhere it livesLifetime
ChatMessageRing buffer of 200 plus a map by idUntil pushed out by newer messages
Pending own messageOutbox map by clientId, also shown as a rowUntil the server answers
Waiting messagesArray capped at 500 while paused; delay queue capped at 1,000 while syncingUntil shown or dropped
Recently deleted idsSet with a 60-second expiryFor late replays only
User summary (name, color, badges)Map by user id, LRU of 2,000Session
Emote catalogMap by emote id; HTTP cache for imagesSession, refreshed by ETag
Room stateSmall object: slow mode, pin, poll, my status, connectionSession
Viewer settings (Top or Live chat, sync to video, pause on hover)localStorageAcross visits

A ring buffer is a fixed-size array with a moving start index: adding a message when it is full overwrites the oldest slot instead of shifting every element. React state wants a new array on every change, though, and copying 200 items once per 150 ms costs microseconds. So this guide keeps a plain capped array that behaves like a ring. What matters is the cap, and trimming the map in the same step, as append below does.

ts
type ChatMessage = {
  id: string;          // server id, also the React key
  seq: number;         // room order, used as the stream cursor
  ts: number;          // server time accepted, ms since epoch (for video sync)
  clientId?: string;   // set on own messages, to match echoes
  authorId: string;
  segments: Segment[]; // text, emote, mention: never raw HTML
  status: "pending" | "sent" | "held" | "rejected" | "failed" | "removed";
  reason?: string;     // shown on rejected or removed rows
  summary?: { count: number }; // "GOAL x 900" rows
};

type ChatState = {
  rows: ChatMessage[];               // at most 200, oldest first
  byId: Map<string, ChatMessage>;    // same 200, for O(1) delete and replace
  following: boolean;
  waiting: ChatMessage[];            // at most 500, only while paused
  lastSeq: number;                   // for Last-Event-ID
};
ts
function append(state: ChatState, batch: ChatMessage[], cap = 200): ChatState {
  const fresh = batch.filter((m) => !state.byId.has(m.id)); // a replay can resend rows we have
  const all = [...state.rows, ...fresh];
  const dropped = all.slice(0, Math.max(0, all.length - cap));
  const byId = new Map(state.byId);
  for (const m of fresh) byId.set(m.id, m);
  for (const m of dropped) byId.delete(m.id); // forget this line and the map leaks
  return { ...state, rows: all.slice(-cap), byId };
}
One flush, one new state: skip ids already shown, append, and trim the array and the map together.
The client data model. The rows array holds 200 messages: a flush adds 1001 and 1002 at the end and trims 801 and 802 from the start. The byId map holds the same 200 ids, and 801 and 802 are deleted from it in the same step. While paused, messages 1003 to 1040 wait in a separate buffer capped at 500.rows#801#802#803...#1000#1001#1002trimmedaddedbyId#801#802#803...#1000#1001#1002delete from both in one stepwaiting#1003...#1040only while paused, max 500
Trim the array and the map together, or the map quietly keeps every message of the match alive. The waiting buffer only exists while the reader is paused.

Check your understanding · Exercise 11

After a 3-hour match, heap snapshots show memory grew by 90 MB. Which are likely causes? Select all that apply.

Interface: the stream events and the HTTP calls

The API has one read stream and a few small writes. Every stream event has an id (the room sequence) and a type, so the client can route it with a switch and resume with Last-Event-ID.

text
GET /rooms/match-4812/events?view=top&maxRate=15
Accept: text/event-stream
Last-Event-ID: 98231        (sent by EventSource on reconnect)

retry: 5300

id: 98232
event: message
data: {"id":"m_98232","ts":1792521953250,"authorId":"u_77","segments":[{"kind":"text","text":"what a save "},{"kind":"emote","id":"e_12","name":"keeper"}]}

id: 98233
event: delete
data: {"id":"m_98190"}
Opening the stream. The query tells the edge the view and the rate budget.
Event typePayloadClient action
snapshot and resetRoom state plus the newest 50 messagesReplace the store
messageOne message, or a summary row with a countThrough sync gate and flush
delete, purge-user, clear-chatMessage id, user id, or nothingTombstone rows at once
room-settingsSlow mode seconds, members-only, emote-onlyUpdate composer rules
pin, unpin, poll-open, poll-results, poll-closePin or poll objects; totals at most once per secondUpdate room store, through the sync gate
reactionsCounts per type for the last secondFeed the reaction layer
you-are-timed-out, you-are-bannedEnd time or noneDisable composer with a notice
text
POST /rooms/match-4812/messages
{ "clientId": "5b1e...", "text": "what a save :keeper:" }

201 { "id": "m_98240", "seq": 98240, "ts": 1792521954010, "status": "sent" }
202 { "id": "m_98241", "status": "held" }
429 { "error": "slow_mode", "retryAfterMs": 7000 }
409 { "error": "duplicate" }
422 { "error": "blocked_content" }
403 { "error": "timed_out", "until": 1792522254000 }

POST /rooms/match-4812/reactions   { "counts": { "heart": 6, "fire": 2 } }
POST /messages/m_98190/reports      { "reason": "harassment" }
POST /polls/p_31/votes               { "choice": "c_2" }
Sending, with an idempotency key and typed errors.

Errors use a stable error code, not a sentence, so the client can show a localized message and decide whether to restore the text. Auth is a session cookie on the same site, because EventSource cannot send an Authorization header; with withCredentials: true it also works across subdomains if CORS allows credentials.

Check your understanding · Exercise 12

Why does every stream event carry the room sequence number as its SSE id? Select all that apply.

Performance budget and accessibility

A budget per frame and per match

A 60 Hz screen draws a frame every 16.7 ms. The browser needs part of that for style, layout, paint, and compositing, and the video player needs some for its own JavaScript. Give chat a written budget and measure against it on a real mid-range Android phone, not a laptop. The targets below are design goals, and the costs next to them are estimates to check, not measurements.

Budget itemTargetHow we meet it
Chat work in any one frameUnder 4 msOne flush per 150 ms tick, at most 20 new rows, memoized rows
Chat work per secondUnder 40 ms (4 percent)About 7 flushes of roughly 2 to 4 ms each (our estimate)
Long tasks caused by chatNone over 50 msNo per-message renders; reset snapshots of 50 rows only
Interaction to Next Paint on the composerUnder 200 ms at the 75th percentileComposer state is local; flushes never block keystrokes for long
Network for chatUnder 10 KB/s typical, 30 KB/s peakServer sampling at 15 per second, compact JSON, compression on the stream
Heap growth over 3 hoursFlat within a few MBEvery structure capped; pooled reaction elements
DOM nodes in the railUnder 1,500200 rows of about 5 to 7 nodes each
  • Measure in the field: a PerformanceObserver for long-animation-frame entries (Chromium) shows which scripts made frames slow; tag them by feature so chat's share is visible next to the player's.
  • Hidden tab: document.visibilityState === "hidden" stops the flush (rAF already stops) and tells the server maxRate=0 through a tiny POST, or simply closes the stream after 60 seconds hidden and resumes with a reset when visible.
  • Collapsed rail: same as hidden for messages, but keep the reaction counts and the pinned message.
  • Battery: animated emotes and floating reactions are the biggest continuous cost. Both are capped, and both stop when reduced motion is on.

Accessibility: a chat is a log, not an alert

WAI-ARIA has a role for exactly this: role="log", a region where new information is added in order and old information may disappear. It carries an implicit aria-live="polite". That is fine for a team chat with 5 messages a minute. At 15 messages per second it fails: screen readers handle a flood differently, but they tend to either queue rows and fall far behind real time or cut announcements short, and either way they drown out everything else, including the echo of the user's own typing.

Decision How does a screen reader user follow a fast live chat?

A live region over the whole list

role="log" with polite announcements for every new row.

Pros

  • No extra UI.

Cons

  • Floods the speech queue at any real rate; impossible to use during a match.

Silent list, read at your own pace

The list is role="log" with aria-live="off".

Rows are reachable with a roving focus or the screen reader's own reading keys, while the list is paused.

Pros

  • The user controls the pace.
  • No flood.

Cons

  • Nothing tells the user that something important happened.

Silent list plus a few targeted announcements

One separate, small polite live region announces only what matters to this user: mentions of them, the result of their own send, a poll opening, being timed out.

At most one announcement every few seconds.

Pros

  • Important events are heard; the crowd is not.
  • Keeps the user's own actions confirmed.

Cons

  • A setting is needed for users who want more, for example "Announce messages: off, mentions only, slow sample".

What we pick: Silent list plus targeted announcements, with a setting for a slow sample (one message every 5 seconds) for users who want the crowd's mood read out. The screen readers lesson covers live region politeness in detail.

  • Moving focus into the list pauses it, exactly like scrolling up. Users of screen readers and keyboards get the same freeze-and-count behavior as everyone else, and the resume button is a real button.
  • Each row reads as one sentence: author, badges as text ("moderator"), then message, with emotes read by their alt names.
  • Mentions and moderator rows are marked with an icon or border plus text, not color alone; author name colors are checked for contrast against the background, and adjusted if a user picks a dark one. The visual accessibility lesson covers contrast.
  • prefers-reduced-motion: reduce turns off floating reactions, animated emotes, and smooth scrolling.
  • Text can be resized to 200 percent without breaking rows; the rail can be widened or opened as its own pop-out window.

Check your understanding · Exercise 13

Which choices make the rail usable with a screen reader during a goal? Select all that apply.

Running it in the interview

This is a hard question because it has many parts. You will not cover all of them in 45 minutes. Show you see the whole picture early, then go deep on the two or three that make this problem different from a normal chat.

Draw the wireframe first (rail, pinned bar, list, paused bar, composer), then the design diagram. Say the numbers out loud: 3,000 messages per second arriving, about 1 readable, 15 sent per connection, 7 flushes per second, 200 rows. Numbers turn opinions into a design.

MinutesWhat to doWhat to draw or say
0 to 5Scope questions: rate, audience size, history, moderation, video latency."Peak is thousands per second, so I will design for showing a readable sample, not everything."
5 to 10Requirements and what makes it hard.The five properties: rate, bursts, shared device, hostile crowd, two clocks.
10 to 18Architecture and transport.The design diagram; SSE plus POST; fan-out through edges; resume with a cursor.
18 to 28Deep dive one: volume and rendering.Server sampling with priorities, 150 ms flush, cap of 200 rows, why no virtualization.
28 to 35Deep dive two: scroll and sending.Pause on intent, freeze and count; optimistic echo with clientId; slow mode; late moderation.
35 to 42Deep dive three, as the interviewer prefers: video sync, moderation, or accessibility.The timeline of a spoiled goal and the delay queue.
42 to 45Wrap up.The budget table and what you would measure first.

Follow-up questions to expect

Think first

"What if the product wants viewers to scroll back through the whole match?"

Think first

"How do you test this?"

Think first

"The edge sends 15 messages per second. Why not let the client choose 60?"

Check your understanding · Exercise 14

You have 10 minutes left and have not covered video sync, moderation, or accessibility. What is the best use of them? Select all that apply.

Summary

  • Nobody can read thousands of messages per second. Decide what to show on the server first: moderation, Top chat filtering, summarizing repeats, then per-connection sampling with priorities for the viewer's own messages, mentions, and moderator events.
  • Use one push stream for reading (SSE or WebSocket) and plain HTTP for writing. Fan out through edge nodes that subscribe to a room topic once, and resume with a cursor; gaps in the crowd are fine, gaps in your own messages are not.
  • Make render cost depend on time, not traffic: buffer outside React, flush every 150 ms inside requestAnimationFrame, keep the newest 20 per flush, and cap the list at 200 keyed, memoized rows.
  • Pause auto-scroll on user intent, freeze the list while paused, count what is waiting, and resume in one commit.
  • Echo the viewer's own message at once with a clientId, and show every server outcome on it: sent, held, slow mode, duplicate, blocked, timed out.
  • Moderation events travel in the same ordered stream and apply by id, even to paused lists and messages still waiting.
  • Chat can spoil a goal the viewer has not seen yet. Release messages when the player's on-screen wall-clock time reaches them.
  • A chat is a log: keep the list silent for screen readers, announce only what matters to the user, and respect reduced motion.

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