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.
| Question | Answer we assume | What 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. |
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.
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?
Drop: delivery of every message to every reader, full history and search, unread counts, and reliable ordering across a long gap. A viewer who reconnects after 20 seconds does not need the 60,000 messages they missed.
Keep: the user's own messages must never vanish silently, moderation removals must reach everyone, and the order of what is shown must match the server's order so replies like "lol same" make sense.
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?
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 kind | Rule at the edge | Why |
|---|---|---|
| The viewer's own message | Always sent, never sampled | The author must see their message in the stream, or they post it again. |
| Mentions of the viewer | Always sent | Someone is talking to them. |
| Moderator, host, pinned, poll events | Always sent | These change what the viewer may do or must see. |
| Delete and ban events | Always sent if the target message was sent to this connection | A removed message must disappear everywhere it appeared. |
| Summary rows (GOAL x 900) | High priority | One row stands for hundreds of messages. |
| Ordinary messages | Sampled to the token budget, spread evenly over the second | Evenly 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?
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.
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 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?
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.
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
}
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
memoand 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
memonotices 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.
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?
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.
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
- Scroll instantly while following. Smooth scrolling 7 times a second never finishes one animation before the next one starts.
- Set
overflow-anchor: noneon 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 setscrollTopto the bottom right after, the reader sees no jump. - When the viewer sends a message, resume following, so they see their own message land.
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?
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
- 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.
- It creates
clientId = crypto.randomUUID(), appends a pending echo to the list, resumes following, clears the box, and starts the slow mode countdown. - It sends
POST /rooms/:id/messageswith the text andclientId. The server usesclientIdas an idempotency key, so a retry after a timeout cannot post twice. - The server replies
201with the server id and sequence number: the echo becomes sent. Or it replies202withheld: the echo shows "Held for review, only you can see this". Or4xxwith a reason: the echo shows the reason and offers edit and resend where that makes sense. - When the stream later delivers a message whose
clientIdmatches 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. - If a moderator removes the message later, a
deleteevent replaces the echo's text, just like any other row.
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 answer | Echo shows | Composer does |
|---|---|---|
| 201 accepted | Normal message | Nothing more |
| 202 held for review | Dimmed, "Held for review, only you can see this" | Nothing more |
| 429 slow mode or rate limit | "Not sent: wait 7s" with a resend button | Restores 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 retry | Retries once with the same clientId, then waits for the user |
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
| Check | Client | Server |
|---|---|---|
| Blocked words | A small hint list warns before sending: "This may not be posted". | The real list, with normalization (case, spacing, look-alike letters), decides. |
| Links | Not turned into links for normal users. | Rejected or stripped for non-trusted users. |
| Spam and repeats | Local duplicate check, for feedback only. | Rate limits, duplicate rules, spam classifiers, and Top chat filtering. |
| Hold for review | Shows the held state on the author's echo. | Sends risky messages to a review queue instead of fan-out. |
| Delete, timeout, ban | Applies 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.
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: hiddenas a final guard. - Back it up with a Content Security Policy that blocks inline scripts, so a missed escape cannot run code.
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>,
);
}
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
imghas fixedwidthandheight(for example 28 by 28) so rows never shift when the image loads, plusdecoding="async"and asrcsetfor 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: reduceis 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.
- A tap shows a floating icon locally at once, so the button feels alive.
- The client counts taps and sends one
POST /rooms/:id/reactionswith counts per type every 2 seconds at most, and caps a single viewer at 10 per second. - The server sums all viewers per second and broadcasts
{ type: "reactions", counts: { heart: 18400, fire: 6100 } }. - 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. - Icons animate with
transformandopacityonly, which the compositor can run off the main thread, and are removed onanimationend. A pool of at most 30 elements is reused. - With
prefers-reduced-motion: reduce, no floating: a counter beside the button ticks up instead.
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?
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.
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()!);
}
}
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.
| Piece | Responsibility | Why it is its own piece |
|---|---|---|
| Stream client | Owns 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 gate | Holds 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 scheduler | Buffers 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 store | Ring 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 store | Slow mode seconds, pinned message, open poll, viewer status (ok, timed out, banned), connection state. | Changes rarely; many small components read it. |
| Catalog caches | Emote catalog by id, user badges by id. | Shared by rows; fetched once per channel. |
| Outbox | Pending own messages by clientId, the POST and retry logic, the slow mode countdown shared over BroadcastChannel. | The only path that must be reliable. |
| Reaction batcher | Counts 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 components | ChatPanel, 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.
clientId joins the echo with its copy from the stream.- A viewer posts. The chat service runs moderation, assigns a room sequence number and timestamp, and publishes once to the room topic.
- Each edge node receives it once, summarizes repeats, samples per connection, and writes an SSE event to each of its local streams.
- Mia's stream client parses the event and passes it to the sync gate, which holds it until her player reaches its timestamp.
- The flush scheduler takes it on the next tick, together with up to 19 others, and commits one batch to the chat store.
- MessageList renders the new rows; a layout effect scrolls to the bottom if following.
- 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?
It is a reasonable step if profiling shows parsing is a real cost, for example with a high sampling rate on low-end phones. A worker can parse JSON, summarize, and gate, then post one batch per tick to the main thread.
At 15 to 20 messages per second, though, parsing costs well under a millisecond per second. The worker adds a message hop and makes access to the player's clock awkward, since the player lives on the main thread. Start on the main thread, measure, and move it only if the numbers say so.
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.
| Entity | Where it lives | Lifetime |
|---|---|---|
| ChatMessage | Ring buffer of 200 plus a map by id | Until pushed out by newer messages |
| Pending own message | Outbox map by clientId, also shown as a row | Until the server answers |
| Waiting messages | Array capped at 500 while paused; delay queue capped at 1,000 while syncing | Until shown or dropped |
| Recently deleted ids | Set with a 60-second expiry | For late replays only |
| User summary (name, color, badges) | Map by user id, LRU of 2,000 | Session |
| Emote catalog | Map by emote id; HTTP cache for images | Session, refreshed by ETag |
| Room state | Small object: slow mode, pin, poll, my status, connection | Session |
| Viewer settings (Top or Live chat, sync to video, pause on hover) | localStorage | Across 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.
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
};
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 };
}
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.
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"}
| Event type | Payload | Client action |
|---|---|---|
snapshot and reset | Room state plus the newest 50 messages | Replace the store |
message | One message, or a summary row with a count | Through sync gate and flush |
delete, purge-user, clear-chat | Message id, user id, or nothing | Tombstone rows at once |
room-settings | Slow mode seconds, members-only, emote-only | Update composer rules |
pin, unpin, poll-open, poll-results, poll-close | Pin or poll objects; totals at most once per second | Update room store, through the sync gate |
reactions | Counts per type for the last second | Feed the reaction layer |
you-are-timed-out, you-are-banned | End time or none | Disable composer with a notice |
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" }
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 item | Target | How we meet it |
|---|---|---|
| Chat work in any one frame | Under 4 ms | One flush per 150 ms tick, at most 20 new rows, memoized rows |
| Chat work per second | Under 40 ms (4 percent) | About 7 flushes of roughly 2 to 4 ms each (our estimate) |
| Long tasks caused by chat | None over 50 ms | No per-message renders; reset snapshots of 50 rows only |
| Interaction to Next Paint on the composer | Under 200 ms at the 75th percentile | Composer state is local; flushes never block keystrokes for long |
| Network for chat | Under 10 KB/s typical, 30 KB/s peak | Server sampling at 15 per second, compact JSON, compression on the stream |
| Heap growth over 3 hours | Flat within a few MB | Every structure capped; pooled reaction elements |
| DOM nodes in the rail | Under 1,500 | 200 rows of about 5 to 7 nodes each |
- Measure in the field: a
PerformanceObserverforlong-animation-frameentries (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 servermaxRate=0through a tinyPOST, 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?
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
altnames. - 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: reduceturns 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope 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 10 | Requirements and what makes it hard. | The five properties: rate, bursts, shared device, hostile crowd, two clocks. |
| 10 to 18 | Architecture and transport. | The design diagram; SSE plus POST; fan-out through edges; resume with a cursor. |
| 18 to 28 | Deep dive one: volume and rendering. | Server sampling with priorities, 150 ms flush, cap of 200 rows, why no virtualization. |
| 28 to 35 | Deep dive two: scroll and sending. | Pause on intent, freeze and count; optimistic echo with clientId; slow mode; late moderation. |
| 35 to 42 | Deep dive three, as the interviewer prefers: video sync, moderation, or accessibility. | The timeline of a spoiled goal and the delay queue. |
| 42 to 45 | Wrap 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?"
Then the rail gets a history mode. While following, nothing changes. When the user scrolls past the 200 rows in memory, the client fetches older pages with a cursor (GET /rooms/:id/messages?before=seq) and the list becomes virtualized, because now it can be long.
History pages come from storage, not the stream, and they show the sampled or Top chat view, since the full room would be millions of messages.
Think first
"How do you test this?"
A fake stream that replays a recorded match at 1x to 50x speed, with scripted bursts, deletes, and resets. Unit tests on the store: caps hold, map and array stay in sync, deletes tombstone the right rows, echoes dedupe by clientId.
A performance test on a throttled CPU that plays a 5,000 per second burst and asserts no long tasks from chat. A soak test that plays 3 simulated hours and checks heap size. Screen reader checks with the list paused and playing.
Think first
"The edge sends 15 messages per second. Why not let the client choose 60?"
Because nobody can read 60, and the cost lands on the device that is also playing video. The client can choose lower, for example on a slow phone or with the rail collapsed, but the server's cap protects both the network and the experience.
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.
References
- Using server-sent events, MDN Event stream format, id, retry, and reconnection.
- Server-sent events, HTML Living Standard The exact rules for Last-Event-ID and the reconnection time.
- LiveChatMessages: list, YouTube Live Streaming API A real chat API built on polling with a server-chosen pollingIntervalMillis.
- LiveChatMessages: streamList, YouTube Live Streaming API The server-streaming alternative, which resumes from the last page token after a disconnect.
- Learn about live chat, YouTube Help What the Top chat and Live chat views show.
- Moderate live chat, YouTube Help Slow mode and the other live chat moderation settings.
- IRC concepts, Twitch Developers Twitch chat as IRC commands over a WebSocket.
- Twitch chat and chatbots, Twitch Developers Documented send limits, such as 20 messages per 30 seconds for a regular account.
- New chat moderation tools, Twitch Blog The Chat Delay setting that gives moderators a head start.
- Window: requestAnimationFrame() method, MDN Frame-aligned flushing, and why it pauses in background tabs.
- Avoid large, complex layouts and layout thrashing, web.dev Forced synchronous layout and how to batch reads and writes.
- Long Animation Frames API, Chrome for Developers Measuring which scripts make frames slow in the field.
- WAI-ARIA 1.2: the log role, W3C The role for chat logs and its implicit polite live region.
- hls.js API documentation playingDate and latency, the inputs for syncing chat to the video.
- RFC 8216: HTTP Live Streaming Defines EXT-X-PROGRAM-DATE-TIME, which maps video to wall-clock time.
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.
- Server-sent eventsOne HTTP response that never finishes: the server keeps writing small text events to it, and the browser's
EventSourceparses them, reconnects on its own, and resumes where it left off. The simplest push channel when data flows from server to client. - 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.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- Cross-site scripting (XSS)Cross-site scripting is when text from outside your app ends up running as code inside your page, with every power your own code has: the most common and most damaging frontend security bug, and the one frontend engineers own most directly.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.