Design a Twitch-style live stream page
The viewer page of a live streaming site like Twitch, YouTube Live or Kick: a live player, chat beside it, follow and subscribe buttons, and the channel page around it. The hard parts are keeping viewers a few seconds behind real life without stalling, handling a stream that starts, drops, reconnects and ends while people watch, and keeping a heavy page fast while chat, alerts and overlays update many times a second.
The question
Design the viewer page of a live streaming site like Twitch. A viewer opens a channel, the live video starts, chat runs beside it, and they can follow, subscribe, and keep watching in a small player while they browse other channels.
Popular channels have hundreds of thousands of viewers at once. The streamer and the chat should feel close to real time, and the page should keep working when the streamer's internet drops for a moment. Walk me through the player, the stream's life cycle, and how the page stays live.
Real-life examples: Twitch, YouTube Live, Kick, Amazon IVS player demos, Facebook Live
Requirements: what a live page must do
A live stream page looks like a video page with a chat box, but it works very differently. On a video page the file already exists: the player can download ahead, and nothing on the page changes while you watch. On a live page the video is being made while you watch it.
The streamer can drop off, come back, end the stream, or send all their viewers to another channel. Thousands of people type in chat about what they just saw, so a delay of 20 seconds turns into a real problem.
Some parts of this page have their own guides, linked at the end under "Try next". The YouTube guide covers HLS and DASH (the two common ways to send video as small files over HTTP) and adaptive bitrate (ABR: switching quality to fit the network) for recorded video. The video player guide covers the player component: controls, buffering, and keyboard use.
The live comments guide covers a fast chat rail in depth, the Discord guide covers emote rendering and a gateway connection, and the video conferencing guide covers WebRTC, the browser's real-time media stack. This guide links to them instead of repeating them, and spends its time on what is special about live: latency, the live edge, stream state, and a page that changes under the viewer.
Start by asking questions whose answers move the design. The table lists the answers this guide assumes.
| Question | Answer we assume | What it changes |
|---|---|---|
| How far behind real life may viewers be? | Default 2 to 5 seconds. A low-bandwidth or compatibility mode may sit at 10 to 20 seconds. | Picks the delivery protocol (low-latency HLS, not plain HLS, not WebRTC) and the player buffer size. |
| How many viewers per stream? | Most streams have under 50. The biggest reach 300,000 to over 1,000,000 at once. | Video must come from a CDN over HTTP. Viewer counts and alerts must be fanned out, never sent per user. |
| Can viewers rewind the live stream? | Yes, a DVR window (how far back viewers can rewind) of up to 2 hours, then the recording (VOD) after the stream ends. | The player must handle being behind live, and the page must swap live for VOD later. |
| What happens around the video? | Chat, follow, subscribe, viewer count, polls, predictions, hype trains, raids. | One real-time connection carries many topics. Overlays must not slow the player. |
| Which devices? | Desktop browsers first, then mobile web and smart TVs through the same player code. | Low-end devices need lower quality and a lighter page. Safari plays HLS natively. |
| Can people keep watching while they browse? | Yes, a mini-player follows them around the site. | The player must live outside the routed page so it survives route changes. |
| Ads? | Mid-roll ads, triggered by the streamer or on a schedule. | The player must react to ad markers inside the stream. |
Functional requirements
- Open a channel and start playing live video at the live edge in under 2 seconds on a good connection.
- Show the stream state: offline, live, reconnecting, ended, and the recording when it is ready.
- Show chat beside the player, in theatre mode, and in a popout window.
- Follow and subscribe, with the button responding at once.
- Live updates: viewer count, follower and subscriber alerts, polls, predictions, hype trains, and raids that move viewers to another channel.
- Rewind within the DVR window and jump back to live with one button.
- Theatre mode, fullscreen, picture-in-picture, and a mini-player that keeps playing on other pages.
- Go-live notifications for followers, even when the site is closed.
Non-functional requirements
- Latency: 2 to 5 seconds glass to glass (from the streamer's camera to the viewer's screen) by default, and stable: it should not creep up during a 6 hour stream.
- Smoothness: fewer than 1 stall per hour on a steady connection. A stall on live is worse than on VOD, because there is almost no buffer to hide it.
- Scale: 1,000,000 viewers on one stream must not mean 1,000,000 requests to our origin servers. The CDN absorbs the video, and the real-time service fans out shared events.
- Page speed: first frame within 2 seconds of opening the page, and INP (Interaction to Next Paint, the delay from a click to the next frame) under 200 ms even while chat scrolls at 50 messages per second.
- Accessibility: keyboard control of the player, captions when the stream has them, and a chat that a screen reader can use.
Out of scope
The streamer's side (broadcast software, the stream manager dashboard), the transcoding farm, recommendations on the home page, and payments processing. We cover the payment button and its states, not the money flow. Chat internals stay in the live comments guide, and the player's controls in the video player guide.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most? Select all that apply.
What makes a live page hard
Before choosing anything, name the forces. Every later decision in this guide comes back to one of these five.
- The video does not exist yet. A VOD player can keep 30 to 60 seconds of video in its buffer, so a slow network rarely shows. A live player can only buffer what has already been made. At 3 seconds behind live, the buffer can never hold more than about 3 seconds. Low latency and a big safety buffer pull in opposite directions.
- Everyone is watching the same moment. On YouTube, 1,000,000 people watch a video at different points. On a live stream they watch the same few seconds, and they talk about it in chat. If my video is 20 seconds behind yours, your chat spoils my game. Latency is a product feature, not only a tech number.
- The source is fragile. A streamer broadcasts from a home connection. Their upload can drop for 10 seconds, their game can crash, they can end the stream by mistake and restart it. The page must show each of these clearly and recover without a reload.
- The page changes while you watch it. Viewer count, new followers, a poll that opens and closes, a hype train with a countdown, a raid that moves you to another channel. Many small updates arrive per second from a real-time connection, on top of chat.
- It is a heavy page that must stay smooth. Video decoding, a chat list adding dozens of rows per second, animated emotes, and overlays all share one main thread and one battery. Viewers watch for hours, so slow leaks and slow drift matter.
The wireframe shows the page we are designing: the player in the centre and chat on the right. Under the player is the channel bar with the streamer's name, the follow and subscribe buttons, and the viewer count. Below the fold are the channel's panels and recent videos, and the navigation rail on the left lists followed channels with live dots.
Notice what each region needs. The player needs a media pipeline and a latency strategy, and the chat needs a fast rail. The channel bar needs live data and optimistic buttons (buttons that show success before the server confirms), the panels below the fold need nothing at first paint, and the left rail needs a live list of who is streaming. That split becomes the architecture in a later chapter.
Think first
A VOD player recovers from a 4 second network hiccup without the viewer noticing. Why does the same hiccup cause a visible stall on a live stream 3 seconds behind real time?
A VOD player sitting on 30 seconds of buffer just plays from the buffer while the network recovers. The viewer sees nothing.
A live player 3 seconds behind real time can hold at most about 3 seconds of video, because the rest has not been made yet. A 4 second gap empties the buffer and the picture freezes. This is the core trade of live video: every second you cut from latency is a second less of protection.
Check your understanding · Exercise 2
Which of these problems are specific to live pages and do not exist on a VOD page like YouTube's watch page? Select all that apply.
The latency budget: where the seconds go
Glass-to-glass latency is the time from light hitting the streamer's camera to the same frame lighting up the viewer's screen. The viewer never sees the stages, only the total. To cut it, we must know which stage holds the seconds. Here is the pipeline of a typical live platform.
- Capture and encode on the streamer's PC, in software like OBS. The encoder compresses frames into H.264 or similar. Cost: 0.1 to 1 second, depending on encoder settings.
- Ingest: the encoder pushes the stream to the platform's nearest ingest server, usually over RTMP (or RTMPS, its TLS version) or SRT. Newer setups can use WHIP (RFC 9725, 2025), a standard way to send a WebRTC stream to a server. Cost: one network trip plus a small buffer, 0.1 to 0.5 seconds.
- Transcode: the platform decodes the stream and re-encodes it into a ladder of qualities (1080p60, 720p, 480p, 160p) so every viewer gets one that fits their bandwidth. Cost: 0.5 to 2 seconds.
- Package: a packager cuts each quality into segments (small files of a few seconds) and writes a playlist that lists them. A segment can only be published once it is complete, so this stage costs up to one whole segment duration.
- CDN: segments and playlists spread through edge servers close to viewers. The first viewer at an edge pulls from the origin, the rest get a cached copy. Cost: 0.05 to 0.5 seconds.
- Discovery: the player must learn that a new segment exists, usually by reloading the playlist. With plain polling, that waits on average half a segment.
- Player buffer: the player keeps a few segments in hand before it plays, as a safety margin against network hiccups. This is usually the biggest single stage.
Add it up for plain HLS with Apple's recommended 6 second segments. The HLS spec tells players not to start closer than 3 target durations from the end of the playlist, so the buffer alone is about 18 seconds. Add one segment of packaging (6 s), half a segment of discovery (3 s), and about 2 seconds for encode, ingest, transcode and CDN: roughly 29 seconds.
With 2 second segments, the same rules give 6 + 2 + 1 + 2, about 11 seconds. That is why plain HLS usually sits in the 10 to 30 second range. All the numbers in this chapter are typical ranges and rough estimates, not measurements of any one platform.
The figure shows the point: the player buffer and segment size dominate. Encode, ingest and CDN are small and mostly outside the frontend's control. The frontend owns the biggest stage, the buffer, and picks the protocol that sets segment size. So latency is very much a frontend decision.
Decision How should the video reach the viewer's browser?
What we pick: Low-Latency HLS as the default, with plain HLS as the fallback when a viewer stalls often or the device struggles, and WebRTC only for small interactive rooms such as a guest joining the stream. This matches what large platforms offer. Amazon IVS, built on the technology that powers Twitch, sells two modes: low-latency channels at under 5 seconds for big audiences, and WebRTC-based real-time stages at under 300 ms. YouTube Live lets streamers pick normal, low, or ultra-low latency, trading features and quality for speed.
How Low-Latency HLS removes the seconds
Apple announced Low-Latency HLS in 2019 and folded the final version into the HLS spec in 2020. It attacks the three biggest stages one at a time.
- Packaging wait. Instead of waiting 6 seconds for a whole segment, the packager publishes parts of a fraction of a second up to about 1 second (
EXT-X-PART). A part can be played as soon as it is listed. - Discovery wait. The player asks for the playlist with
_HLS_msnand_HLS_partquery parameters naming the next part it expects. The server holds the request open until that part exists, then answers at once. No polling interval, no wasted half-segment. The playlist advertises this withEXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES. - Request trip. An
EXT-X-PRELOAD-HINTtag names the part that comes next. The player requests it early, and the server streams the bytes as the encoder makes them. - Buffer. The playlist sets
PART-HOLD-BACK, the distance from the live edge where the player should sit. The spec requires at least 2 part durations and recommends 3 or more, so with 1 second parts the player sits about 3 seconds back instead of 18.
#EXTM3U
#EXT-X-TARGETDURATION:4
#EXT-X-PART-INF:PART-TARGET=1.0
#EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES,PART-HOLD-BACK=3.0,CAN-SKIP-UNTIL=24
#EXT-X-MEDIA-SEQUENCE:266
#EXT-X-PROGRAM-DATE-TIME:2026-10-21T19:04:12.000Z
#EXTINF:4.0,
seg266.mp4
# ... segments 267 to 269 ...
#EXTINF:4.0,
seg270.mp4
#EXT-X-PART:DURATION=1.0,URI="seg271.part0.mp4",INDEPENDENT=YES
#EXT-X-PART:DURATION=1.0,URI="seg271.part1.mp4"
#EXT-X-PART:DURATION=1.0,URI="seg271.part2.mp4"
#EXT-X-PRELOAD-HINT:TYPE=PART,URI="seg271.part3.mp4"
EXT-X-PROGRAM-DATE-TIME ties a segment to a wall-clock time. We will need it twice: to measure real latency in the player, and to sync chat to the video, as the live comments guide proposes.
What Twitch and others do
Twitch introduced its low-latency mode around 2018. Its player shows the delay as "Latency To Broadcaster" in the advanced stats. Twitch has not published a full spec of its delivery, so treat the details as observed, not official.
Open-source tools that read Twitch streams, such as Streamlink, handle a Twitch-specific playlist tag, EXT-X-TWITCH-PREFETCH. It lists upcoming segment URLs before they are finished: the same idea as the standard preload hint, built before LL-HLS was final. Kick is widely reported to run on Amazon IVS.
YouTube shows the same trade from the product side. Its API docs say ultra-low latency does not support closed captions or resolutions above 1080p, and its help centre says both low and ultra-low latency drop 4K. Latency is a product trade-off, not a free win.
Try the numbers yourself. Pick a protocol, a segment and part size, and a network. The lab adds up each stage with the same rough estimates as the figure above, shows how much buffer protects you against a hiccup of that network, and how long catch-up takes at a faster playback rate after a stall.
Check your understanding · Exercise 3
A viewer on plain HLS with 6 second segments is 28 seconds behind. Which changes cut the most latency? Select all that apply.
The live player: the edge, the window, and catching up
The video player guide builds a player for recorded video: a timeline from 0 to the end, seeking, buffering, quality menus. A live player keeps most of that and changes the meaning of time. There is no end. The "end" is the live edge, the newest moment the CDN can serve, and it moves forward by one second every second.
In the browser, the video element's seekable property gives the range of time you can play. For live, the end of the last range is the live edge, and the start is the oldest moment the playlist still lists (the DVR window). duration is often Infinity, so the normal progress bar math breaks. Players like hls.js also expose hls.liveSyncPosition (where the player wants to be), hls.latency (how far behind the edge it is now), and hls.targetLatency.
video.seekable is the live edge and moves forward every second. A live viewer sits at the target, a little behind the edge, so the buffer has room; anything further back gets a grey badge and a jump-to-live button.Joining at the live edge
The naive player starts at the first segment in the playlist. With a 2 hour DVR window, that puts a new viewer 2 hours in the past. The opposite mistake is starting at the very last part: the buffer is empty, and the first tiny network delay stalls.
The right start is the hold-back point: PART-HOLD-BACK seconds behind the edge for LL-HLS, or about 3 target durations for plain HLS. Players do this for you when configured with a target. hls.js uses liveSyncDurationCount (3 segments by default) or liveSyncDuration in seconds, and starts LL-HLS streams at PART-HOLD-BACK when its lowLatencyMode is on.
For a fast first frame, start at the lowest or a middle quality rung, not the highest. The first part at 480p downloads in a fraction of the time of 1080p60, and ABR moves up within a few seconds once it has measured the network. On a channel page, preconnect to the CDN origin in the page head and fetch the master playlist URL with the page data, so the player does not wait for JavaScript before its first request.
The live indicator and "jump to live"
The viewer needs to know whether they are watching live. The common pattern: a red LIVE badge when the player is within a small distance of its target latency, and a grey badge, often with the delay ("-1:42"), when they paused or rewound. Clicking it seeks to the live sync position. The rule for "live" must have slack: use something like latency under target plus 3 seconds, otherwise the badge flickers during small stalls.
type EdgeState = { isLive: boolean; behindSec: number; canRewind: boolean };
export function readEdge(video: HTMLVideoElement, targetSec: number): EdgeState {
const r = video.seekable;
if (r.length === 0) return { isLive: true, behindSec: 0, canRewind: false };
const edge = r.end(r.length - 1);
const behindSec = Math.max(0, edge - video.currentTime);
return {
isLive: behindSec < targetSec + 3,
behindSec,
canRewind: edge - r.start(0) > 30, // DVR window worth showing
};
}
export function jumpToLive(video: HTMLVideoElement, targetSec: number) {
const r = video.seekable;
if (r.length) video.currentTime = r.end(r.length - 1) - targetSec;
if (video.paused) void video.play();
}
Catching up after a stall
Every stall adds latency: the player waits 2 seconds for data, and when it resumes, it is 2 seconds further behind. After 5 stalls over an hour, a viewer who started 3 seconds behind is 13 seconds behind and does not know it. Chat drifts out of sync with the video. This is latency creep.
Decision How does the player get back to its target latency after it falls behind?
What we pick: Combine them: speed up gently (1.05 to 1.1) when a little behind and the buffer is healthy, seek only when far behind (more than about 10 seconds over target), and never speed up below about 1 second of buffer. If a viewer paused or rewound on purpose, catch-up is off until they click "jump to live".
Check your player's defaults before relying on them. In hls.js, rate catch-up is off by default (maxLiveSyncPlaybackRate is 1). When turned on, it only acts within about one target duration of the target and with more than 1 second of buffer; further back counts as DVR viewing. hls.js also adds 1 second to its target after each stall by default (liveSyncOnStallIncrease), which trades latency for fewer repeat stalls.
Adaptive bitrate with almost no buffer
The YouTube guide explains ABR for VOD: estimate bandwidth, use buffer level as a safety signal, and switch rungs (the quality levels of the ladder). At the live edge, two things change. First, the buffer signal barely moves: it sits around the target of 2 to 3 seconds whether the network is good or bad, so buffer-based rules lose most of their information.
Second, bandwidth is hard to measure. A part fetched with a preload hint arrives at the speed the encoder makes it, not the speed of the network, so a naive throughput sample says the network is slow when it is not.
- Measure throughput only on bytes that were already available (old parts, whole segments), or time the first byte and the transfer rate separately. dash.js, for example, ships low-latency ABR rules (L2A and LoL+) for this reason.
- Switch down early and up slowly: drop a rung as soon as a part takes longer than its own duration to arrive, and wait 10 to 20 seconds of good samples before going up.
- Use rendition reports (
EXT-X-RENDITION-REPORT): each playlist lists the newest segment and part of the other rungs, so a switch can go straight to one blocking request for the right point, saving a round trip. - Cap quality to the player's size: there is no point fetching 1080p for a 400 pixel mini-player. The same rule drops to a lower rung on low-end devices that drop frames (
video.getVideoPlaybackQuality()reports dropped frames).
Ads in the middle of a live stream
Live ads are triggered while the stream runs: the streamer clicks "run ad", or a schedule fires. The broadcast industry marks these points with SCTE-35 cue messages inside the stream. The HLS spec maps them to EXT-X-DATERANGE tags with SCTE35-OUT and SCTE35-IN attributes, and older non-standard EXT-X-CUE-OUT and EXT-X-CUE-IN tags are still common. Exact formats vary by vendor, so treat the details as one common approach.
There are two main ways to play the ad. Server-side ad insertion (SSAI) stitches ad segments into the playlist itself, so the player sees one continuous stream and ad blockers cannot easily skip it. The player only needs to know an ad is playing, to show "Ad 1 of 2, 0:15" and hide the live badge. Apple's HLS Interstitials, which hls.js also supports, are a standard middle ground: the playlist schedules a separate ad asset at a date range.
Client-side insertion pauses the live stream and plays the ad in a second player, which means the viewer falls behind by the ad length and catch-up must handle it. Twitch has used a picture-by-picture format, where the ad takes the main player and the stream keeps playing, muted, in a smaller frame. Whatever the format, the page must report "in ad" in its state so chat and overlays can react.
Check your understanding · Exercise 4
A viewer stalled 3 times and is now 11 seconds behind a 3 second target, with 2.5 seconds of buffer. What should the player do? Select all that apply.
Stream state: a machine, not a pile of booleans
The naive page has flags: isLive, isLoading, hasError, isEnded. Then the streamer's Wi-Fi drops. The player's segment requests fail, so hasError becomes true and the page shows "Something went wrong". Ten seconds later the stream returns, but nothing resets hasError.
Or the server says the stream is live while the player still has an empty playlist, and the page shows a LIVE badge over a black box. With four booleans there are 16 combinations, and most are nonsense.
A stream has a small, clear life cycle. Write it as a state machine: a list of states, the events that move between them, and what the page shows in each. The state machines lesson covers the technique; here is the machine for a live channel.
| State | What it means | Player shows | Page shows |
|---|---|---|---|
offline | No stream. May have a schedule or a last VOD. | Offline banner with the channel art, or the last VOD paused. | "Offline" in the channel bar, chat still open, "notify me" option. |
starting | The server reports the stream is live, the player is loading the first parts. | Spinner over a still thumbnail. No LIVE badge yet. | Title and category from the event. Viewer count hidden. |
live | Frames are playing near the live edge. | LIVE badge, latency, quality menu. | Viewer count, uptime, live alerts. |
reconnecting | The streamer's ingest dropped, or the playlist stopped growing. | Last frame dimmed with "Reconnecting to the stream". No error. | Chat stays open. The badge stays LIVE. The page does not reset. |
ended | The streamer stopped. The server confirmed. | "Stream ended" card: raid target, follow suggestion, or "watch the recording soon". | Viewer count frozen, overlays closed. |
vod | The recording of the stream is ready. | The VOD player at the start, or at the moment the viewer left. | Chat becomes replay, the page title changes. |
Two sources of truth, one machine
Events reach the machine from two places, and they often disagree for a few seconds. The control plane is the real-time connection: the server sends stream.online, stream.offline, and later vod.ready. The media plane is the player: it sees parts arriving, playlist reloads failing, or the playlist gaining an EXT-X-ENDLIST tag (which means no more segments will come).
stream.onlinecan arrive a few seconds before the first playable part exists at the CDN. Soonlinemoves tostarting, and only the player's firstplayingevent moves tolive. Never show LIVE over a black frame.- The player's playlist stops growing for 2 target durations, while the server still says online: move to
reconnecting. Keep the last frame. Retry the playlist with backoff (1, 2, 4 seconds, capped at about 8). - In
reconnecting, new parts arrive: back tolive. If the server sendsstream.offline: go toended. If neither happens in 90 seconds: go toendedanyway, with the message hedged ("The stream may have ended"). - A streamer who restarts within a minute often gets a new stream id. Treat
stream.onlinewith a new id inendedas a freshstarting, and reload the player with the new playlist URL. vod.readycan arrive minutes or hours later. Only swap tovodif the viewer is still on the page and not watching something else, and keep their scroll position in chat.
type State = "offline" | "starting" | "live" | "reconnecting" | "ended" | "vod";
type Event =
| { type: "ONLINE"; streamId: string } | { type: "PLAYING" }
| { type: "STALLED" } | { type: "RESUMED" }
| { type: "OFFLINE" } | { type: "GIVE_UP" } | { type: "VOD_READY"; vodId: string };
const table: Record<State, Partial<Record<Event["type"], State>>> = {
offline: { ONLINE: "starting" },
starting: { PLAYING: "live", OFFLINE: "offline" },
live: { STALLED: "reconnecting", OFFLINE: "ended" },
reconnecting: { RESUMED: "live", OFFLINE: "ended", GIVE_UP: "ended" },
ended: { ONLINE: "starting", VOD_READY: "vod" },
vod: { ONLINE: "starting" },
};
export function next(state: State, event: Event): State {
return table[state][event.type] ?? state;
}
A STALLED event here means the source stalled, which is not the same as the viewer's own buffering. If the viewer's network is slow but the playlist keeps growing, that is buffering: show a spinner and let ABR drop quality. Only a playlist that stops growing means the source is gone. Mixing them up is the most common bug on live pages: telling viewers "the streamer has connection issues" when it is their own Wi-Fi.
Fire events at the machine and watch what the player and page do. Try a reconnect that succeeds, one that times out, and a restart after the stream ended.
State offline. 0. start: offline
Check your understanding · Exercise 5
The server says the stream is online, but the player has not received a new part for 8 seconds while the viewer's other requests are fast. What should the page show? Select all that apply.
Chat beside the player: only the integration
The live comments guide designs the chat rail itself: transport, batching 50 messages per second into one render, a virtualized list, the "chat paused" rule when the viewer scrolls up, moderation, and sanitizing text. Here we only cover what changes when that rail sits next to a live player: timing, emotes, moderator tools, and where chat lives when the layout changes.
Chat time and video time
Chat travels in about a second. Video, as the latency chapter showed, arrives 2 to 30 seconds late depending on the viewer. A viewer on a 15 second fallback sees "GG" in chat before the final kill.
The live comments guide proposes a fix: every message carries its server time, the player reports the wall-clock time of the frame on screen (hls.playingDate, from EXT-X-PROGRAM-DATE-TIME), and the client holds each message until the video reaches it. No large platform documents doing this for live chat, so present it as a proposal.
The live page decides when the delay applies, because only it knows the player's state:
- In
live, sync is on if the viewer is more than about 4 seconds behind. Under that, the difference is too small to matter and the delay only makes chat feel slow. - In
reconnecting, the video clock stops. Do not freeze chat: release everything, because chat is where people talk about the drop. - When the viewer rewinds into the DVR window, stop syncing and show "Chat is live. You are 12 minutes behind", with the jump-to-live button. Holding 12 minutes of chat in memory is not worth it.
- In
vod, chat becomes replay: messages are fetched by video time, the same idea at a different source. - Streamers can also turn on a chat delay for moderation. Twitch's setting holds chat for a few seconds so moderators can remove messages before viewers see them. It is a different reason for delay, set per channel, and it stacks with ours.
Emotes
Live chat is emote-heavy: a hype moment can put 30 copies of the same animated emote on screen. The Discord guide covers parsing emote codes, sprite sheets, caching, and pausing animation for reduced motion. The live-specific part is scope.
A Twitch channel has its own subscriber emotes, and third-party emote sets are popular too. Fetch the channel's emote set with the channel data, cache it per channel id, and parse messages against global, channel, and personal sets. When the viewer is raided into another channel, fetch that channel's set before messages render, or new emote codes show as plain text for a second.
Moderator tools
A moderator's page is the same page with more power: hover a message to delete it, time out the sender, or ban them; switch chat to slow mode, followers-only, or subscribers-only. Two frontend rules matter.
First, actions must be one click or one key away, because a raid of spam can bring hundreds of messages a minute. Second, the moderation result arrives as an event on the real-time connection and applies to everyone at once, including messages still held in the sync queue: a deleted message is removed from the queue and never shown.
Theatre mode and popout chat
In theatre mode the player fills the window and chat sits on the right as a narrower, darker column, or hides. Keep it the same chat instance: unmounting and remounting would drop the buffer and the scroll state. Use CSS to move it, not a different component tree.
Popout chat opens in a separate window, often on a second monitor. That window runs its own copy of the chat app with its own connection, and the main page hides its chat column so the viewer does not pay for two rails.
The two windows talk over BroadcastChannel, a browser API that passes messages between windows of the same origin. The popout announces it is open, and the main page sends the player's playing time every second so the popout can still sync chat to the video. When the popout closes, its pagehide handler tells the main page to show chat again.
const bc = new BroadcastChannel(`chat:${channelId}`);
// main page
bc.onmessage = (e) => {
if (e.data.type === "popout-open") setChatDocked(false);
if (e.data.type === "popout-closed") setChatDocked(true);
};
setInterval(() => {
bc.postMessage({ type: "video-time", playingDate: hls.playingDate?.getTime() ?? null });
}, 1000);
// popout window
bc.postMessage({ type: "popout-open" });
addEventListener("pagehide", () => bc.postMessage({ type: "popout-closed" }));
Check your understanding · Exercise 6
A viewer opens popout chat, then rewinds the main player 10 minutes. Which behaviours are right? Select all that apply.
The page shell: theatre, picture-in-picture, and a player that survives navigation
Viewers on live sites browse while they watch. They click a raid target, open another channel's page to check its schedule, or look at their followed list. Twitch and YouTube both keep a small player in a corner while you do that. On a live stream this matters more than on VOD: if the player is torn down and rebuilt, the viewer loses 2 to 5 seconds rebuilding the buffer, sees a black flash, and falls further behind live.
The naive SPA renders the player inside the channel route. Navigate to /directory and React unmounts the route, the video element is destroyed, and hls.js is torn down with its buffer. Going back to the channel starts from zero.
Decision How does playback survive route changes?
What we pick: A player in the app shell with slots, plus browser picture-in-picture as an extra button for viewers who want the video outside the browser. The slot design is what makes theatre mode, the mini-player, and returning to the channel all instant, because the element never moves in the DOM. Only its box moves.
video element, so its MediaSource and buffer survive; normal, theatre and mini are three boxes for the same element.How the host and the slot work
A video element can be moved in the DOM without stopping: the browser only pauses media that stays out of the document. The problem is React. It cannot move a DOM node, so rendering the player under a different parent, or changing a portal's target, unmounts it and creates a new element without its MediaSource and buffer. The newer moveBefore() (Chrome 133, Firefox 144, not yet in Safari) keeps more state during a move, but React does not use it for you.
So the host never moves the element. It keeps it in one fixed container in the shell and changes that container's box with CSS: position: fixed with the slot's rectangle in normal and theatre modes, and a corner rectangle in mini mode. A ResizeObserver and a scroll listener on the slot keep the box in place. When the slot leaves the screen because the viewer scrolled down the channel page, the host can switch to mini mode too, as YouTube does.
// in the root layout, outside the router outlet
<PlayerHost />
<Outlet />
// in the channel page
function ChannelPage({ channel }: { channel: Channel }) {
const ref = usePlayerSlot(channel.id); // registers the slot's box with the host
return (
<>
<div ref={ref} className="player-slot" />
<ChannelBar channel={channel} />
</>
);
}
// usePlayerSlot: on mount, tell the host "show channel X here" and observe the box;
// on unmount, tell the host "no slot", and the host docks to the corner.
- Navigating to another live channel loads that channel in the same host: the old stream is stopped and the new one starts at its edge. Do not keep two live players running.
- Mini-player controls are play or pause, mute, close, and "back to channel". Closing stops the stream and releases the network: a hidden live player keeps downloading parts every second.
- Background tabs. When the tab is hidden and the viewer is not listening (muted), drop to the lowest rung or pause. When unmuted, keep playing audio, which people often do with music streams. The
visibilitychangeevent tells you. - Media Session. Set
navigator.mediaSession.metadatawith the stream title and channel art so the OS media controls and lock screen show what is playing. - Fullscreen uses
element.requestFullscreen()on the player container, not thevideoelement, so custom controls and the chat overlay come along. iPhone Safari is the exception: it only puts thevideoelement itself in fullscreen, with native controls.
The Document Picture-in-Picture API (documentPictureInPicture.requestWindow(), in Chromium browsers since Chrome 116) can float a whole small document, not just a video. That allows a floating window with the player and a slim chat, but it is not in Safari or Firefox, so treat it as an enhancement behind feature detection.
Check your understanding · Exercise 7
Why does the player live in the app shell rather than inside the channel route? Select all that apply.
Real-time page updates: one connection, many topics
Besides video and chat, a live page receives a steady trickle of events: the viewer count every 30 seconds or so, a new follower alert, a subscription, a poll opening, a prediction locking, a hype train level going up, the stream title changing, a raid. The left rail also needs to know when followed channels go live.
The naive build gives each feature its own way in: the viewer count polls GET /viewers every 10 seconds, polls open their own WebSocket, the left rail polls GET /followed/live every minute. With 1,000,000 viewers on one stream, the viewer count poll alone is 100,000 requests per second, all asking the same question. Each extra socket costs a TLS handshake, memory on both sides, and its own reconnect logic.
Decision How do page events reach the browser?
What we pick: One WebSocket per tab with topic subscriptions, the same design as the Discord gateway, with a short-cache HTTP endpoint behind every topic as the fallback and for the first paint. Twitch's own public API went the same way: its older PubSub WebSocket was retired in April 2025 in favour of EventSub, which offers a WebSocket transport with subscription types such as stream.online and channel.follow. The websockets lesson covers heartbeats and reconnect in depth.
Envelopes, sequence numbers, and snapshots
Every message has the same envelope: a topic, a type, a per-topic sequence number, and a payload. On connect, the client subscribes and the server answers with a snapshot for each topic (current viewer count, the open poll, the hype train state) and then deltas. After a reconnect, the client sends the last sequence it saw per topic. If the server still has the missing deltas, it replays them; if not, it sends a fresh snapshot.
State and events are treated differently. A poll's vote totals are state, so a snapshot is always safe. An alert ("Sam just subscribed") is an event, and old ones are simply dropped after a reconnect, because showing a 3 minute old alert is worse than missing it.
| Topic | Example events | Rate on a big stream | Client handling |
|---|---|---|---|
stream.{id} | online, offline, title or category change, viewer count | 1 every 10 to 60 s | Feeds the stream state machine. Viewer count is sampled and rounded ("84.2K"), never animated digit by digit. |
chat.{id} | messages, deletes, timeouts, mode changes | 10 to 100 per s | Handed to the chat rail and its batching, as in the live comments guide. |
community.{id} | follow and sub alerts, polls, predictions, hype train | Bursts of 1 to 20 per s | Alerts are queued and shown one at a time. Poll totals are throttled to 1 render per second. |
user.me | own sub confirmed, gift received, whisper | Rare | Confirms optimistic writes. |
follows.live | a followed channel went live or offline | Rare | Updates the left rail and the in-app toast. |
Alerts and overlays without chaos
A big streamer can get 500 gifted subs in a few seconds, and 500 toasts would be useless. Group them: "Alex gifted 500 subs" is one event from the server, and smaller bursts are merged on the client within a 2 second window.
Poll and prediction cards update their bars at most once per second, using a CSS transform for the bar width so updates do not trigger layout. A hype train shows a level and a countdown. The countdown is computed from the server's expiresAt, never ticked by received messages, so a slow socket does not freeze it.
Raids: the event that changes the page
When a stream ends, a streamer can raid another channel: their viewers are sent to the target, and the target's chat sees "Mia is raiding with 3,200 viewers". For the frontend, a raid is a navigation started by the server. The sequence diagram shows the flow, including the viewer who opts out.
- Show a banner with the target channel and a countdown, and a "Stay here" button. Moving people without warning is disorienting. Twitch shows a countdown and a "Leave Raid" option before the move.
- Before the countdown ends, prefetch the target's channel data and master playlist, and preconnect to its CDN host, so the new stream starts fast.
- Navigate with the SPA router, so the player host swaps the stream and the URL changes to the target. Add the raid source to the URL or the history state so analytics can attribute the visit.
- Spread the load. 3,200 viewers arriving in one second is fine; 200,000 is a thundering herd on the target's playlist. The server can add a random delay of 0 to 5 seconds per client in the raid event, and the CDN serves everyone after the first.
Check your understanding · Exercise 8
The page's WebSocket reconnects after 40 seconds offline. What should it do for each topic? Select all that apply.
Follow, subscribe, and go-live notifications
Follow is free and reversible. Subscribe costs money every month. Bits or other tips cost money once. These three buttons sit side by side in the channel bar, and they need three different levels of optimism.
Decision When should the button show success before the server confirms?
What we pick: Optimistic by risk. Optimism is for actions that rarely fail and are cheap to undo. Follow qualifies. Money does not: show a clear pending state and celebrate only on confirmation. Disable a sub button while its request is in flight so a double click cannot charge twice, and send an idempotency key with the request as a second guard.
Follow has two side effects the button cannot see. The left rail should add the channel, and the viewer should be asked once whether they want go-live notifications. Put the follow state in the server cache keyed by channel id, so the channel bar, the left rail, and a hover card all update from one write.
export function useFollow(channelId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (follow: boolean) =>
api(`/channels/${channelId}/follow`, { method: follow ? "PUT" : "DELETE" }),
onMutate: async (follow) => {
await qc.cancelQueries({ queryKey: ["follow", channelId] });
const before = qc.getQueryData(["follow", channelId]);
qc.setQueryData(["follow", channelId], { following: follow });
return { before };
},
onError: (_err, _follow, ctx) => {
qc.setQueryData(["follow", channelId], ctx?.before);
toast("Could not update follow. Try again.");
},
onSettled: () => qc.invalidateQueries({ queryKey: ["followed-live"] }),
});
}
Go-live notifications
A follower wants to know the moment a favourite channel goes live, even with the site closed. That is web push: the browser subscribes through a service worker with the Push API, the server stores the subscription, and when stream.online fires, a fan-out job sends a push message to each follower who opted in. The push notifications lesson covers subscription, VAPID keys, and permissions. Three live-specific rules:
- Ask at the right moment. Never on page load. Ask right after a follow, in our own UI first ("Get notified when Mia goes live?"), and call
Notification.requestPermission()only when they say yes. A browser prompt that is denied cannot be asked again by the site. - Fan-out takes time. A channel with 2,000,000 followers cannot notify everyone in one second. Expect delivery over tens of seconds to minutes, so the notification should open the channel page, which shows the true current state.
- Collapse, do not stack. Use the same notification
tagper channel so a streamer who restarts 3 times does not produce 3 notifications. Every web push must show a notification, so the service worker cannot silently skip a stale one. It can check the stream and show "Mia was live" instead, and the server can set the pushTopicheader so an undelivered go-live message is replaced by a newer one.
Inside the site, the same event reaches open tabs on the follows.live topic: a live dot appears in the left rail, and a small toast can appear. The notifications center guide covers the bell and its list; a go-live item is just one more type there. On iOS, web push only works for sites added to the home screen (since iOS 16.4), so the mobile apps remain the main channel for most viewers.
Check your understanding · Exercise 9
Which of these actions are safe to show optimistically? Select all that apply.
Architecture, data model, and interface
Now put the decisions together. The page splits into parts that change at very different speeds: video frames 60 times a second, chat 50 times a second, community events a few times a second, the channel info a few times an hour. Each speed gets its own owner, so a fast part never forces a slow part to re-render.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| App shell | Layout, left rail, router outlet, PlayerHost, the one real-time connection. | Everything that must survive navigation lives above the router. |
PlayerHost and media engine | One video element with hls.js (or native HLS in Safari), ABR, catch-up, latency reporting. | Owns the heaviest state (buffers, MediaSource). Talks to the rest only through a small event API. |
| Stream state machine | Combines control events and media events into one state. | One place decides "live" or "reconnecting", so the badge, overlay and chat agree. |
| Realtime client | WebSocket, topic subscriptions, heartbeats, resume by sequence, routing to stores. | Features subscribe by topic and never touch the socket. |
| Chat rail | The live comments design: batching, virtual list, sync queue. | Its own store and render loop, so 50 messages a second do not re-render the player or channel bar. |
| Community store | Viewer count, alerts queue, poll, prediction, hype train, raid. | Throttled to about 1 update per second per widget. |
| Server cache | Channel, follow state, emote sets, VODs, schedule. TanStack Query or similar. | Request data with caching, dedupe, and optimistic writes. |
| Lazy panels | About panels, schedule, clips, recent VODs below the fold. | Code-split and loaded when near the viewport. Not needed for the first frame. |
Read the design diagram from the outside in. The shell owns three long-lived services: the player host, the realtime client, and the server cache. The routed page is thin: it reads from stores and declares a player slot. Nothing in the routed page owns a connection or a media buffer, which is why navigation is cheap.
How data moves on first load and while watching
On first load, the server renders the channel page shell with the channel data embedded: name, avatar, title, live status, the master playlist URL, and a poster image. That gives a fast first paint and correct metadata for link previews. The browser preconnects to the video CDN and the player starts fetching the playlist as soon as its script runs, in parallel with hydrating the rest.
While watching, three streams flow in parallel. Video parts flow from the CDN to the media engine over HTTP. Events flow from the gateway over the WebSocket to the realtime client, which routes them to the state machine, the chat store, and the community store. Writes (follow, chat send, vote) go out over HTTP to the API, and their confirmation often comes back over the socket.
Think first
Should the channel page be server-rendered, given the video cannot be?
Yes, for everything except the video. Server rendering gives the channel name, title, avatar, live status and poster in the first HTML, so the page looks right at once and link previews on social sites show the stream title. It also lets us put the playlist URL in the HTML, so the player can start fetching without a separate API call.
The live parts (viewer count, chat, alerts) are rendered on the client after the socket connects. The stream state in the HTML is a snapshot that might be seconds old, so the client must trust the socket and the player over it.
Check your understanding · Exercise 10
Chat messages arrive at 60 per second. Which design choices keep them from slowing the player and the channel bar? Select all that apply.
Data model: what the client keeps and where
The client holds a few kinds of data, and each kind has a natural home. Getting the home right is what keeps updates cheap.
| Entity | Lives in | Changes | Notes |
|---|---|---|---|
Channel | Server cache, seeded from the SSR payload | Rarely (title, category) | Socket title changes patch the cache entry. |
StreamSession | Stream state store | On state events | Has its own id: a restart is a new session. |
PlayerStats | Media engine, sampled into a small store 1 per second | Constantly | Latency, buffer, rung, dropped frames. Never in React state at frame rate. |
FollowState | Server cache, keyed by channel id | On user action | Optimistic writes. |
Community (count, poll, prediction, hype train) | Community store | Many per second, throttled | Replaced by snapshots on reconnect. |
ChatMessage | Chat store, ring buffer of about 200 | Up to 100 per second | See the live comments guide. |
EmoteSet | Server cache, per channel, long cache life | Rarely | Prefetched on raid. |
| Player prefs (volume, quality, low latency on) | localStorage | On user action | Read before the player starts. |
| Current channel and mode | URL and shell store | On navigation | The URL is /channelname; theatre mode can be a query flag. |
type Channel = {
id: string; login: string; displayName: string; avatarUrl: string;
title: string; category: string; followers: number;
};
type StreamSession = {
id: string; channelId: string;
state: "offline" | "starting" | "live" | "reconnecting" | "ended" | "vod";
startedAt: number | null; // for the uptime counter
playlistUrl: string | null; // master playlist, low-latency capable
dvrWindowSec: number; // 0 if rewind is off
vodId: string | null;
};
type PlayerStats = {
latencySec: number; bufferSec: number; rung: string;
droppedFrames: number; playbackRate: number; inAd: boolean;
};
type Community = {
viewerCount: number; seq: number;
poll: { id: string; title: string; choices: { id: string; votes: number }[]; endsAt: number } | null;
hypeTrain: { level: number; progress: number; goal: number; expiresAt: number } | null;
raid: { targetLogin: string; viewers: number; departsAt: number } | null;
};
Two rules hold this model together. First, timers come from server times: the uptime counter is now - startedAt, the poll countdown is endsAt - now, the raid countdown is departsAt - now. The client corrects its clock once by comparing the server's Date header or a server time in the socket hello message, so a phone whose clock is 40 seconds off still shows the right countdown.
Second, nothing that changes faster than once per second goes into a store that React components subscribe to broadly. Player stats are sampled, chat is batched, and poll totals are throttled.
Check your understanding · Exercise 11
Where should the poll's countdown get its time from? Select all that apply.
Interface: the APIs the page talks to
The page talks to three surfaces: an HTTP API for data and writes, a WebSocket gateway for events, and the video CDN for playlists and media. The CDN speaks HLS, which we covered. Here are the other two.
GET /api/channels/mia HTTP/1.1
HTTP/1.1 200 OK
Cache-Control: public, max-age=5, stale-while-revalidate=30
Content-Type: application/json
{
"channel": { "id": "c_812", "login": "mia", "displayName": "Mia", "title": "Ranked grind", "category": "Chess", "followers": 412000 },
"stream": { "id": "s_99a", "state": "live", "startedAt": 1792578000000,
"playlistUrl": "https://video.example.com/s_99a/master.m3u8", "dvrWindowSec": 7200 },
"viewerCount": 18240,
"serverTime": 1792581012345
}
Personal data (am I following, am I subscribed, my emote sets) is not in this response, because it would make the response uncacheable. It comes from a second, private request: GET /api/me/channels/c_812 with Cache-Control: private, no-store. The CDN caching lesson covers why mixing public and private data in one response breaks edge caching.
| Method | Path | Purpose | Notes |
|---|---|---|---|
GET | /api/channels/{login} | Channel, stream, viewer count | Public, cached 5 s at the edge. |
GET | /api/me/channels/{id} | Follow and sub state, my emotes | Private. |
PUT / DELETE | /api/channels/{id}/follow | Follow or unfollow | Idempotent, so retries are safe. |
POST | /api/channels/{id}/subscriptions | Start a subscription | Needs an Idempotency-Key header. Returns 202 with a pending id; confirmation comes on user.me. |
POST | /api/polls/{id}/votes | Vote | One vote per user, enforced on the server. |
GET | /api/channels/{id}/vods?cursor= | Recent videos panel | Cursor pagination, loaded lazily. |
POST | /api/push/subscriptions | Store a web push subscription | Body is the browser's PushSubscription as JSON. |
{ "op": "subscribe", "topics": { "stream.c_812": 0, "community.c_812": 0, "chat.c_812": 0, "user.me": 0 } }
{ "op": "snapshot", "topic": "community.c_812", "seq": 5501,
"data": { "viewerCount": 18240, "poll": null, "hypeTrain": null, "raid": null } }
{ "op": "event", "topic": "stream.c_812", "seq": 77, "type": "stream.offline",
"data": { "streamId": "s_99a", "at": 1792585300000 } }
{ "op": "event", "topic": "community.c_812", "seq": 5502, "type": "raid.started",
"data": { "targetLogin": "sam", "viewers": 18010, "departsAt": 1792585310000, "jitterMs": 3100 } }
{ "op": "error", "code": "topic_forbidden", "topic": "mod.c_812" }
Errors use one shape everywhere: a code the client switches on, and a message for logs, not for the user. HTTP errors return { "error": { "code": "sub_payment_declined", "message": "..." } }. The client maps codes to friendly text and actions ("Your card was declined. Update payment").
The player component's interface
The player host exposes a small imperative API and a few events. The rest of the app never touches hls.js directly, which keeps it swappable (for example for native HLS in Safari or a vendor player such as the IVS player).
interface LivePlayer {
load(src: { playlistUrl: string; streamId: string; startAt?: "edge" | number }): void;
setMode(mode: "normal" | "theatre" | "mini" | "hidden"): void;
jumpToLive(): void;
setLowLatency(on: boolean): void; // false = larger buffer, fewer stalls
stop(): void;
on(event: "state", fn: (e: "playing" | "waiting" | "source-stalled" | "source-resumed" | "ended") => void): () => void;
on(event: "stats", fn: (s: PlayerStats) => void): () => void; // about once per second
on(event: "ad", fn: (a: { inAd: boolean; remainingSec?: number }) => void): () => void;
playingDate(): number | null; // wall-clock ms of the frame on screen, for chat sync
}
Check your understanding · Exercise 12
Why is the follow state fetched separately from the public channel payload? Select all that apply.
Performance and accessibility on a heavy page
A live page runs for hours with a video decoder, a busy chat, animated emotes, overlays, and a WebSocket all at once. The runtime performance lesson covers long tasks and INP in general. Here are the live page's budgets and the specific moves that meet them.
| Metric | Budget | Main threat | Main fix |
|---|---|---|---|
| Time to first frame | Under 2 s on a good connection | Waiting for JS before fetching the playlist | Playlist URL in the HTML, preconnect, start at a middle rung. |
| LCP | Under 2.5 s | The poster image is the largest element | Serve the poster at the right size and preload it with a link rel=preload that has fetchpriority="high". |
| INP | Under 200 ms | Chat batches and overlay updates block clicks | Batch chat, throttle overlays, split long tasks, keep handlers short. |
| CLS | Under 0.1 | Panels, alerts, and ads pushing content | Fixed player aspect box, overlays positioned over the player, panels with reserved height. |
| Dropped frames | Under 1 percent | Main thread busy during decode on low-end devices | Lower rung, pause emote animation, fewer DOM updates. |
| Memory over 6 hours | Flat | Chat history, alert queues, listeners | Ring buffers, cleanup on topic change, cap every queue. |
INP while chat is busy
INP (Interaction to Next Paint) measures how long the page takes to show the response to a click or key press. If a 60 ms chat render is running when the viewer clicks Follow, the click waits. Three moves keep interactions fast.
First, the chat rail renders in batches every 100 to 200 ms and keeps each batch under about 10 ms by virtualizing the list. Second, overlays and the viewer count update at most once per second. Third, input handlers do the minimum, update the UI, and yield before follow-up work with scheduler.yield() where supported, or a setTimeout fallback, so the next frame can paint first.
Marking chat batch updates as a transition (React's startTransition) also lets React pause the chat render when a click arrives.
What not to load on first paint
- The channel panels, schedule, clips, and VOD list below the fold: code-split and load when an
IntersectionObserversees them within one screen of the viewport. - Mod tools: a separate chunk loaded only for moderators.
- Poll, prediction and hype train components: loaded when their first event arrives, not before. Most streams never open a poll.
- The emote picker and the subscribe checkout: loaded on first click, with a prefetch on hover or focus.
- Third-party extensions or panels in iframes:
loading="lazy"and sandboxed.
Low-end devices
On a cheap phone or old laptop, 1080p60 decoding plus a busy chat can drop frames. Watch video.getVideoPlaybackQuality(): if more than about 5 percent of frames drop over 10 seconds, cap ABR one rung lower. If the device reports little memory (navigator.deviceMemory in Chromium) or the viewer turned on a data saver, start at 480p and turn off animated emotes by default. Offer "Audio only" for viewers who just listen; it saves most of the bandwidth and the decode work.
Accessibility
- Keyboard. Every control is a real
buttonin a sensible tab order: play, mute, volume, live badge (jump to live), quality, captions, theatre, fullscreen. Single-key shortcuts (space or k to play, m to mute, f for fullscreen, t for theatre) only fire when focus is on the player, never while typing in chat. The keyboard navigation lesson covers roving focus and shortcut conflicts. - Live state for screen readers. Announce state changes once in a polite live region: "Stream is reconnecting", "Stream ended", "Raiding Sam in 10 seconds". Do not announce the viewer count every update.
- Captions. Live captions exist only if the stream carries them: CEA-608 or 708 captions inside the video, or WebVTT tracks in the HLS playlist. Some platforms generate automatic captions for live streams; others rely on streamer tools. Latency and captions can conflict: YouTube's API docs say its ultra-low latency mode does not support closed captions. The player shows a CC button only when a caption track is present, and remembers the choice.
- Chat. The live comments guide covers it: a log role without announcing every message, pause on focus, and readable emote alt text.
- Motion. Respect
prefers-reduced-motion: no animated alerts, static emotes, and no auto-scrolling marquees in overlays. - Raids and auto-navigation. A viewer using a screen reader must hear the countdown and have a clear "Stay here" button. WCAG success criterion 3.2.5 (Change on Request, level AAA) asks that changes of context start only on request or can be turned off, which is what that button provides.
Check your understanding · Exercise 13
A viewer on a low-end laptop reports that clicking Follow takes a second during hype moments. Which fixes target the cause? Select all that apply.
Running it in the interview
This problem is wide. If you try to cover chat, the player, the state machine, real-time events and payments equally, you will go shallow on all of them. Interviewers ask it to see whether you know what is special about live: latency and the edge. Make that the centre.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Ask about latency target, audience size, DVR, devices, and what is around the video. State what you will not cover (chat internals, streamer side). | A list of requirements with numbers. |
| 5 to 10 | Sketch the page: player, chat, channel bar, panels, left rail. Name which parts change how fast. | The wireframe. |
| 10 to 20 | The latency budget and protocol choice: HLS vs LL-HLS vs WebRTC, with the stages and the trade against buffer. | The pipeline with seconds per stage. |
| 20 to 28 | The live player: join at the hold-back point, live badge, catch-up by playback rate, ABR at the edge. | The DVR window with the edge and target. |
| 28 to 35 | Stream state machine and the real-time connection with topics. | The state diagram and the topic router. |
| 35 to 42 | Architecture, data model, API. The player in the app shell. Optimistic follow, confirmed subs. | The component diagram. |
| 42 to 45 | Performance and accessibility highlights, then questions. | Nothing new. Point at the existing boxes. |
Say the latency trade out loud early: "Every second we cut from latency is a second less of buffer, so I want low latency by default with a fallback for viewers who stall." That one sentence shows you understand the core of live video. Then keep returning to it: catch-up exists because of it, ABR changes because of it, chat sync exists because of it.
Follow-up questions to expect
Think first
"The product team wants sub-second latency for everyone, for live betting. What changes?"
Delivery moves to WebRTC through a tree of media servers, because segment-based HTTP cannot go below about 1 to 2 seconds. Cost per viewer rises sharply: every viewer is a stateful connection, and CDN caching no longer helps.
I would push back with a split: sub-second for the betting audience, which is smaller and pays, and LL-HLS for everyone else, with the server sending betting events aligned to the video's program date time so each viewer's odds match what they see.
Think first
"A streamer with 500,000 viewers raids a small channel. What breaks?"
The target's playlist and first segments get 500,000 requests in a second, and its CDN edges may not have them cached yet. The fix is jitter: the raid event carries a random delay per client over several seconds, and the client prefetches the playlist during the countdown so the edges warm up.
The target's chat goes from quiet to a flood; the live comments design already samples and batches. The target's emote set and channel data also get hit: they are public, cacheable responses with short edge caching.
Think first
"Viewers say chat is ahead of the video by 10 seconds after a few hours. Why, and how would you know?"
Latency creep: each stall added seconds that were never recovered, or catch-up is off. I would check the player stats each client reports (latency, stalls, playback rate) in real user monitoring, grouped by device and network.
Fixes: turn on gentle catch-up by playback rate, seek when far behind, and sync chat to the player's program date time so even a behind viewer sees chat at the right moment.
Check your understanding · Exercise 14
You have 10 minutes left and have not discussed the player at all. What should you cover first? Select all that apply.
Summary
- Glass-to-glass latency is a budget: segment size and the player buffer dominate it, and the frontend owns the buffer. Low-Latency HLS (parts, blocking reload, preload hints) gets to 2 to 5 seconds while staying cacheable on a CDN.
- Every second cut from latency is a second less of protection against stalls. Default to low latency, fall back to a bigger buffer for viewers who stall, and keep WebRTC for small interactive rooms.
- A live player joins at the hold-back point, not the edge or the start, shows a live badge with slack, and fights latency creep with gentle playback-rate catch-up and seeks only when far behind.
- Model the stream as a state machine fed by two sources: control events from the server and media events from the player. A source stall (playlist stopped growing) is not the viewer's own buffering.
- Keep the player in the app shell and position it over slots, so theatre mode, the mini-player and navigation never destroy the buffer.
- Use one WebSocket with topics, snapshots for state and dropped old alerts after a reconnect. Throttle overlays to about once per second and treat raids as server-started navigation with a warning and jitter.
- Be optimistic for free, reversible actions like follow and votes; show pending states for money. Ask for push permission right after a follow, never on load.
- Protect INP on a busy page: batched chat, throttled overlays, lazy panels, and lower quality on devices that drop frames.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- HTTP Live Streaming 2nd Edition (IETF draft, includes Low-Latency HLS) The spec for partial segments, blocking playlist reload, preload hints, and hold-back.
- Apple: Enabling Low-Latency HTTP Live Streaming Apple's guide to the low-latency tags and server behaviour.
- hls.js API and configuration Live sync, max latency, catch-up playback rate, and latency properties.
- Amazon IVS: low-latency streaming The managed service built on the technology that powers Twitch, with its latency claims.
- YouTube Help: Understand live streaming latency Normal, low, and ultra-low latency, and what each one gives up.
- RFC 9725: WebRTC-HTTP Ingestion Protocol (WHIP) The standard way to push a WebRTC stream into a media server.
- Twitch EventSub documentation Subscription types like stream.online and channel.follow, and the WebSocket transport.
- MDN: HTMLMediaElement.seekable How the browser exposes the playable range, and so the live edge.
- MDN: Picture-in-Picture API Floating the video element and its events.
- MDN: Document Picture-in-Picture API Floating a whole small document, Chromium only.
- web.dev: Interaction to Next Paint The metric to protect on a busy page.
Concepts behind it
- WebRTCThe browser's built-in way to send audio, video, and data directly between users' devices with very low delay: the base of every web video call, and a design with more servers than its "peer to peer" name suggests.
- 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.
- Web push notificationsMessages your server sends through the browser's push service, which wake your service worker to show a notification even when your site isn't open: powerful for re-engagement, and easy to ruin with a bad permission prompt.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- 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.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- 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.