cf.completefrontend
My progress
SYSTEM DESIGN · #124

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.

QuestionAnswer we assumeWhat 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.

Wireframe of a live channel page at twitch-style URL /mia. On the left, a rail of followed channels with live dots. In the centre, the live player with a red LIVE badge and a latency readout of 3 seconds. Under it, the channel bar with Follow, Subscribe, and a viewer count of 18.2K. Below the fold, dashed panels for the about section and recent videos, loaded lazily. On the right, the chat column runs the full height.example.tv/miafollowedsamkaiofflineLIVE3.0 slive playerPlayerHost slotFollowSubscribe18.2Kpanels, VODs: lazychatbatched railsend box
Each region changes at its own speed: video 60 times a second, chat many times a second, the channel bar a few times a minute, and the panels almost never. That split becomes the architecture.

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?

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.

Glass-to-glass latency for three setups, as stacked bars. Each bar, left to right: encode, ingest, transcode and CDN; packaging wait; discovery of the new segment; player buffer. Plain HLS with 6 second segments: 1.8, 6, 3 and 18 seconds, about 29 seconds. Plain HLS with 2 second segments: 1.8, 2, 1 and 6 seconds, about 11 seconds. Low-Latency HLS with half-second parts and a hold-back of 3 parts: 1.8, 0.5, almost 0, and 1.5 seconds, about 3.9 seconds. All values are rough estimates.HLS 6 s~29 s6 s3buffer 18 sHLS 2 s~11 s6 sLL-HLS~3.9 sleft to right: encode to CDN,packaging wait (hatched),discovery, then the playerbuffer (marked)
Rough estimates, the same ones the latency lab below uses. The player buffer and the packaging wait scale with segment size, and they are most of the total. Encode, ingest, transcode and the CDN are a thin slice at the start of every bar.

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?

Plain HLS or DASH with short segments

The packager publishes 2 to 6 second segments. The player polls the playlist and buffers about 3 segments.

Every file is a normal cacheable HTTP response.

Pros

  • Works everywhere: Safari plays HLS natively, other browsers through Media Source Extensions with hls.js or dash.js.
  • Scales to millions through any CDN, because every viewer fetches the same files.
  • Most stable: a large buffer hides network hiccups.

Cons

  • 10 to 30 seconds behind live. Chat feels like talking to the past.
  • Shorter segments help, but every segment must start with a keyframe, so shorter segments mean more keyframes and lower quality at the same bitrate. Playlist polling also multiplies requests.

Low-Latency HLS (or low-latency DASH with chunked CMAF)

Segments are still 2 to 6 seconds, but the packager also publishes partial segments (parts) of about 0.2 to 1 second as soon as they exist.

The player asks for the next playlist version and the server holds the request until it exists (blocking reload). A preload hint tells the player which part comes next, so it can request it before it is done.

Pros

  • Typically 2 to 5 seconds glass to glass in practice.
  • Still plain HTTP: CDN caching keeps working, so it scales like HLS.
  • Backward compatible: players that do not support it ignore the new tags and play the stream as normal HLS, at normal latency.

Cons

  • Needs a CDN that handles held requests and query-string cache keys well.
  • Only about 1 to 3 seconds of buffer, so stalls are more likely on shaky networks.
  • Many more requests per viewer: one playlist and one part every 0.3 to 1 second.

WebRTC delivery

Each viewer holds a WebRTC connection to a media server (an SFU, a server that forwards each stream's packets without re-encoding them).

There are no segments: frames flow as a continuous stream, with a jitter buffer of tens of milliseconds.

Setup can use WHEP, the playback twin of WHIP. WHEP is still an IETF draft, though many servers already support it.

Pros

  • Under 1 second, often 200 to 500 ms. Viewers can talk back to the streamer in real time.
  • The same tech the video conferencing guide uses, so co-streaming and guest calls fit naturally.

Cons

  • Every viewer is a stateful connection to a media server. Normal CDNs cannot cache it, so 1,000,000 viewers need a large fan-out tree of servers, at a much higher cost per viewer.
  • Packet loss shows as glitches instead of waiting. Real-time encoders also give up some compression tools, so quality at the same bitrate is often lower.
  • No DVR or rewind without a separate HLS path.

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_msn and _HLS_part query 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 with EXT-X-SERVER-CONTROL:CAN-BLOCK-RELOAD=YES.
  • Request trip. An EXT-X-PRELOAD-HINT tag 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.
text
#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"
A trimmed low-latency playlist. The player requests the next version with ?_HLS_msn=271&_HLS_part=3 and the server holds it until part 3 of segment 271 exists.

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.

Sequence diagram of Low-Latency HLS. The player asks the CDN edge for the playlist with msn 271 and part 3. The edge forwards the request to the packager, which holds it open. When part 3 exists, the packager answers with the new playlist, and the edge passes it to the player. The player then requests part 4 from the preload hint, and the edge streams its bytes while the encoder makes them.PlayerCDN edgePackagerplaylist, msn=271 part=3held until part 3part 3 is readyplaylist with part 3GET part 4 (hint)bytes as they are made
No polling interval: the held request returns the moment part 3 exists. The preload hint means the request for part 4 is already waiting before part 4 is finished, so the bytes flow as soon as they exist.

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.

Protocol
SegmentPart
Hold back (parts)
NetworkCatch-up
Latency stages for the chosen setup: encode, ingest 0.6 seconds, transcode 1.0 seconds, packaging 0.5 seconds, CDN hop 0.2 seconds, discovery 0.1 seconds, buffer 1.5 seconds. Total about 3.9 seconds. Stall risk: low: survives hiccups back to back.encode, ingest0.6 stranscode1.0 spackaging0.5 sCDN hop0.2 sdiscovery0.1 sbuffer1.5 stotal: about 3.9 sbuffer: 1.5 srisk: lownothing to catch up
Rough estimates. LL-HLS: about 3.9 s glass to glass, 1.5 s of buffer against 0.5 s hiccups. No stall to catch up from on this network.

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.

The live timeline. A long band shows the seekable range, from seekable.start, two hours ago, to seekable.end, the live edge. Near the right end, a mark shows the target position, 3 seconds behind the edge, where a live viewer sits. Further left, a second mark shows a viewer who paused, 1 minute 42 behind. A note says the badge shows live if the viewer is less than the target plus 3 seconds behind.target: edge minus 3 spaused viewer, 1:42 behindseekable.start(0)seekable.end()live edge: newer video not made yetlive badge if behind < target + 3 s
The end of 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.

ts
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();
}
Live state derived from the player, checked on timeupdate (about 4 times per second). Seeking to the edge resets drift.

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?

Do nothing

Keep playing from wherever the stall left us.

Pros

  • Nothing can go wrong. Maximum buffer, so fewer future stalls.

Cons

  • Latency creeps up all session. The low-latency promise breaks within an hour.

Seek to the live edge

When latency exceeds a limit (say target plus 6 seconds), jump to the live sync position.

Pros

  • Instant. Simple. hls.js does it with liveMaxLatencyDurationCount or liveMaxLatencyDuration.

Cons

  • The viewer sees a visible jump and misses seconds of content, sometimes the punchline.
  • The seek itself empties the buffer and can cause another stall.

Speed up playback slightly

When latency is above target, set playbackRate to 1.05 to 1.1. Return to 1.0 when back on target. Players expose this: hls.js maxLiveSyncPlaybackRate, dash.js liveCatchup settings.

Pros

  • No jump, no missed content. With preservesPitch (on by default in browsers), voices sound normal at 1.05.
  • Shedding 3 seconds at 1.1 takes 30 seconds; at 1.05 it takes 60 seconds.

Cons

  • Slower than seeking. Must not run while the buffer is thin, or it causes the next stall.
  • Music streams make the speed-up more noticeable.

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.

StateWhat it meansPlayer showsPage shows
offlineNo 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.
startingThe 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.
liveFrames are playing near the live edge.LIVE badge, latency, quality menu.Viewer count, uptime, live alerts.
reconnectingThe 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.
endedThe streamer stopped. The server confirmed."Stream ended" card: raid target, follow suggestion, or "watch the recording soon".Viewer count frozen, overlays closed.
vodThe 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.
State machine of a live stream. Top row: offline goes to starting on ONLINE, and starting goes to live on PLAYING. From live, STALLED goes down to reconnecting, and RESUMED comes back up to live. From live, OFFLINE goes to ended. From reconnecting, OFFLINE or GIVE_UP goes to ended. From ended, VOD_READY goes to vod, and ONLINE goes back up to starting for a restart. Notes say control events are ONLINE, OFFLINE and VOD_READY, from the server, and media events are PLAYING, STALLED and RESUMED, from the player.offlinestartinglivereconnectingendedvodONLINEPLAYINGSTALLEDRESUMEDOFFLINEOFFLINEGIVE_UPVOD_READYONLINEserver events: ONLINE,OFFLINE, VOD_READYplayer events: PLAYING,STALLED, RESUMED
Live is only reached when the player actually plays, never on the server's word alone. Reconnecting is its own state, so a dropped encoder shows a calm overlay instead of an error, and a restart after ended starts the machine again.

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.online can arrive a few seconds before the first playable part exists at the CDN. So online moves to starting, and only the player's first playing event moves to live. 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 to live. If the server sends stream.offline: go to ended. If neither happens in 90 seconds: go to ended anyway, with the message hedged ("The stream may have ended").
  • A streamer who restarts within a minute often gets a new stream id. Treat stream.online with a new id in ended as a fresh starting, and reload the player with the new playlist URL.
  • vod.ready can arrive minutes or hours later. Only swap to vod if the viewer is still on the page and not watching something else, and keep their scroll position in chat.
ts
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 reducer for the machine. Events that make no sense in a state are ignored, which is what keeps impossible combinations out.

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

Stream state lab. Current state: offline. The player shows Offline, channel art, schedule. The badge shows OFFLINE, chat is open, and the viewer count is hidden. Last event: 0. start: offline.offlinestartinglivereconnectingendedvodplayerOfflinechannel art, schedulebadge: OFFLINEchat: openviewers: hidden0. start: offline
Now offline. 0. start: offline. Events that make no sense in a state are ignored, which is what keeps the page out of impossible combinations.

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.

ts
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" }));
The main page and the popout share one channel name per streamer. A heartbeat covers a popout that crashed without saying goodbye.

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?

Player inside the route, restart on return

Each page that shows video mounts its own player. Save the channel id and time in a store, restart on return.

Pros

  • Simplest component tree. Each route owns its parts.

Cons

  • A black frame and 2 to 5 seconds of rebuffering on every navigation.
  • No way to keep watching on other pages.

Player in the app shell, placed by a slot

One PlayerHost lives in the root layout, above the router outlet, and never unmounts.

The channel page renders an empty PlayerSlot box. The host measures the slot and positions the player over it; with no slot on the page, the host docks into a mini-player in the corner.

Pros

  • The video element, its MediaSource and its buffer are never destroyed. Moving between layouts is instant.
  • Theatre, normal, and mini modes are just three positions of the same element.

Cons

  • The player is positioned over the page, so it must follow the slot on scroll and resize (a ResizeObserver on the slot).
  • Focus order needs care: the player is not inside the page's DOM where it appears.

Browser picture-in-picture only

When the viewer leaves the channel page, call video.requestPictureInPicture() and let the browser float the video.

Pros

  • Floats over other apps and tabs too, not just our site.

Cons

  • Normally needs a user gesture, so it cannot reliably start on navigation.
  • Firefox does not support the API (it has its own built-in toggle), and the floating window has only basic controls.

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.

The player lives in the app shell. Inside the shell, PlayerHost owns one video element with hls.js. Next to it, the router outlet holds the current page: ChannelPage with a PlayerSlot that is only an empty box, or another route like the directory with no slot. The slot reports its rectangle to the PlayerHost. Below, three boxes show the same element in three positions: normal over the slot, theatre filling the width, and mini in the corner.App shell: never unmountsPlayerHostone video + hls.jsRouter outletChannelPagePlayerSlotan empty box/directoryno slot: miniits boxsame element, three boxesnormal: slottheatre: widemini: corner
Navigation only swaps what is inside the router outlet. React never unmounts the 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.

tsx
// 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.
The channel page only declares where the player should be. The shell owns the player itself.
  • 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 visibilitychange event tells you.
  • Media Session. Set navigator.mediaSession.metadata with 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 the video element, so custom controls and the chat overlay come along. iPhone Safari is the exception: it only puts the video element 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?

Polling per feature

Each widget fetches its own endpoint on a timer. Responses are cached at the CDN for a few seconds.

Pros

  • Simple, stateless, and CDN-friendly: a 5 second cache turns 100,000 requests per second into a handful at the origin.

Cons

  • Polls and predictions feel late by up to a whole interval.
  • Many timers, many requests, more battery on phones.

One WebSocket, many topics

The page opens one WebSocket to a gateway and subscribes to topics: stream.1234, chat.1234, user.me, follows.live.

The server sends envelopes tagged with their topic. A small client router hands each one to its feature store.

Pros

  • One connection, one reconnect policy, one heartbeat. Features plug in by topic.
  • Server pushes shared events once per topic, fanned out by the gateway tier, not computed per viewer.
  • Same shape as the Discord gateway: a long-lived socket with typed dispatch events.

Cons

  • Stateful servers, sticky connections, and reconnect storms when a gateway restarts.
  • A slow consumer (a busy tab) can fall behind on a busy topic.

Server-Sent Events per page

One EventSource per page with the topics in the URL. Writes go over normal HTTP.

Pros

  • Built-in reconnect with Last-Event-ID. Works through most proxies.

Cons

  • One direction only, so subscribing to a new topic (opening a popout, following a channel) means a new stream.
  • Over HTTP/1.1, browsers allow about 6 connections per host, which open tabs share. HTTP/2 removes that limit.

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.

One WebSocket carries every topic. The gateway sends envelopes over a single socket to the RealtimeClient in the browser, which routes each by topic: stream topics to the stream state machine, chat topics to the chat store, community topics such as viewer count, polls and raids to the community store, and user.me and follows.live to the server cache.Gatewayfan-out tier1 socketRealtimeClientroutes by topicStreamMachinestream.{id}ChatStorechat.{id}CommunityStorecommunity.{id}Server cacheuser.me, follows
Features subscribe by topic and never touch the socket. Each store sees only its own topic, so a chat flood never wakes the community store, and one reconnect policy covers them all, like the Discord gateway.

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.

TopicExample eventsRate on a big streamClient handling
stream.{id}online, offline, title or category change, viewer count1 every 10 to 60 sFeeds the stream state machine. Viewer count is sampled and rounded ("84.2K"), never animated digit by digit.
chat.{id}messages, deletes, timeouts, mode changes10 to 100 per sHanded to the chat rail and its batching, as in the live comments guide.
community.{id}follow and sub alerts, polls, predictions, hype trainBursts of 1 to 20 per sAlerts are queued and shown one at a time. Poll totals are throttled to 1 render per second.
user.meown sub confirmed, gift received, whisperRareConfirms optimistic writes.
follows.livea followed channel went live or offlineRareUpdates 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.

Sequence diagram of a raid. The gateway sends raid.started with departsAt and a jitter to the page. The page shows the viewer a banner with a countdown and a Stay here button. The page prefetches the target's playlist from the CDN, which returns it. When the countdown and jitter end, the page navigates the viewer to the target channel. The failure path: the viewer presses Stay here, and the page keeps the ended card instead.ViewerPageGatewayCDNraid.startedbanner, 10 sprefetch playlistplaylist, cachednavigate to /samor: Stay herekeep ended card
A raid is a navigation the server starts, so the viewer gets a warning and a way out. The countdown hides the prefetch, and the per-client jitter spreads the herd across a few seconds.
  • 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?

Always optimistic

Flip the button at once for every action, roll back on error.

Pros

  • Every click feels instant.

Cons

  • A subscription that shows "Subscribed" and then rolls back after a card decline is a broken promise about money. Sub emotes may have been shown in the meantime.

Never optimistic

Show a spinner on every button until the server answers.

Pros

  • The UI never lies.

Cons

  • Follow feels slow for no reason: it is free, reversible, and almost never fails.

Optimistic by risk

Follow and unfollow: optimistic, with rollback and a toast on failure.

Subscribe and tips: a pending state through a checkout step, success only when the server confirms on user.me.

Pros

  • Fast where it is safe, honest where money is involved.

Cons

  • Two code paths to maintain and test.

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.

Sequence diagram of following a channel. The viewer clicks Follow. The useFollow hook sets following to true in the query cache, and the button re-renders as Following at once. The hook sends PUT follow to the API, which answers 200 OK. The failure path: the API answers 500 or the network is offline, the hook rolls the cache back, and the button re-renders as Follow with an error toast.VieweruseFollowQuery cacheAPIclick Followfollowing: trueFollowingPUT /follow200 OKor 500, offlineroll backFollow + toast
The button changes before the network answers, because follow is free and reversible. On failure, the saved previous value is put back and a toast explains; a subscription never takes this path, it waits for confirmation.

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.

ts
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"] }),
  });
}
Follow as an optimistic mutation with rollback, written for TanStack Query. The left rail reads the same cache entry.

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 tag per 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 push Topic header 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.

PartResponsibilityWhy the boundary is here
App shellLayout, left rail, router outlet, PlayerHost, the one real-time connection.Everything that must survive navigation lives above the router.
PlayerHost and media engineOne 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 machineCombines control events and media events into one state.One place decides "live" or "reconnecting", so the badge, overlay and chat agree.
Realtime clientWebSocket, topic subscriptions, heartbeats, resume by sequence, routing to stores.Features subscribe by topic and never touch the socket.
Chat railThe 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 storeViewer count, alerts queue, poll, prediction, hype train, raid.Throttled to about 1 update per second per widget.
Server cacheChannel, follow state, emote sets, VODs, schedule. TanStack Query or similar.Request data with caching, dedupe, and optimistic writes.
Lazy panelsAbout panels, schedule, clips, recent VODs below the fold.Code-split and loaded when near the viewport. Not needed for the first frame.
Architecture of the live page. Outside the browser on the right: the video CDN with playlists and parts, the gateway with the WebSocket, and the API. In the browser, three shell services face them: PlayerHost with hls.js, the Realtime client, and the query cache. In the middle column, stores: StreamState fed by PlayerHost and Realtime, ChatStore and Community fed by Realtime. On the left, components: Player UI reads StreamState, ChatRail reads ChatStore, Overlays read Community, and ChannelBar reads the query cache.BrowserPlayer UIChatRailOverlaysChannelBarStreamStateChatStoreCommunityPlayerHosthls.js, ABRRealtimeone WebSocketQuery cacheREST dataVideo CDNplaylists, partsGatewaytopicsAPIREST, BFF
Each outside surface has exactly one owner in the shell. Stores sit between owners and components, so a component never talks to the network, and a fast store like chat only wakes its own component.

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.

Data flow of the live page. The SSR server sends HTML with the channel JSON and the playlist URL to the channel shell. The video CDN sends the playlist and parts over HTTP to the media engine. The gateway sends envelopes over the WebSocket to the realtime client. The query cache sends writes like PUT follow and votes to the API, which replies, and the realtime client also confirms writes to the query cache through the user.me topic.SSR serverVideo CDNGatewayAPIChannel shellMedia engineRealtime clientQuery cacheHTML, channel JSONplaylist, partsenvelopes over WSPUT /follow, votesuser.me
Three streams flow in parallel and never share a wire: video over cacheable HTTP, events over one socket, and data and writes over REST. The confirmations of writes can come back on the socket, so the cache listens to both.

Think first

Should the channel page be server-rendered, given the video cannot be?

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.

EntityLives inChangesNotes
ChannelServer cache, seeded from the SSR payloadRarely (title, category)Socket title changes patch the cache entry.
StreamSessionStream state storeOn state eventsHas its own id: a restart is a new session.
PlayerStatsMedia engine, sampled into a small store 1 per secondConstantlyLatency, buffer, rung, dropped frames. Never in React state at frame rate.
FollowStateServer cache, keyed by channel idOn user actionOptimistic writes.
Community (count, poll, prediction, hype train)Community storeMany per second, throttledReplaced by snapshots on reconnect.
ChatMessageChat store, ring buffer of about 200Up to 100 per secondSee the live comments guide.
EmoteSetServer cache, per channel, long cache lifeRarelyPrefetched on raid.
Player prefs (volume, quality, low latency on)localStorageOn user actionRead before the player starts.
Current channel and modeURL and shell storeOn navigationThe URL is /channelname; theatre mode can be a query flag.
ts
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;
};
The core client types. Server times are epoch milliseconds from server clocks, never the device's clock.
The client data model in three groups. The server cache holds Channel, seeded from SSR; FollowState, written optimistically; and EmoteSet, per channel. The live stores hold StreamSession, driven by the state machine; Community, from snapshots and deltas; and ChatMessage, a ring of 200. Elsewhere: PlayerStats lives in the media engine, sampled once per second; prefs live in localStorage; and the current channel lives in the URL.Server cacheLive storesElsewhereChannelseeded from SSRFollowStateoptimisticEmoteSetper channelStreamSessionstate machineCommunitysnapshot + deltasChatMessagering of 200PlayerStatssampled 1 per sPrefslocalStorageRouteURL: /mia
The group decides the update rules: the server cache refetches and writes optimistically, the live stores take snapshots and deltas from the socket, and the rest never goes through React at frame rate.

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.

http
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
}
The channel payload, also embedded in the server-rendered HTML. Short CDN caching keeps a popular channel's page cheap.

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.

MethodPathPurposeNotes
GET/api/channels/{login}Channel, stream, viewer countPublic, cached 5 s at the edge.
GET/api/me/channels/{id}Follow and sub state, my emotesPrivate.
PUT / DELETE/api/channels/{id}/followFollow or unfollowIdempotent, so retries are safe.
POST/api/channels/{id}/subscriptionsStart a subscriptionNeeds an Idempotency-Key header. Returns 202 with a pending id; confirmation comes on user.me.
POST/api/polls/{id}/votesVoteOne vote per user, enforced on the server.
GET/api/channels/{id}/vods?cursor=Recent videos panelCursor pagination, loaded lazily.
POST/api/push/subscriptionsStore a web push subscriptionBody is the browser's PushSubscription as JSON.
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" }
Gateway messages. The client subscribes with its last sequence per topic; the server replies with deltas or a snapshot.

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).

ts
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.

MetricBudgetMain threatMain fix
Time to first frameUnder 2 s on a good connectionWaiting for JS before fetching the playlistPlaylist URL in the HTML, preconnect, start at a middle rung.
LCPUnder 2.5 sThe poster image is the largest elementServe the poster at the right size and preload it with a link rel=preload that has fetchpriority="high".
INPUnder 200 msChat batches and overlay updates block clicksBatch chat, throttle overlays, split long tasks, keep handlers short.
CLSUnder 0.1Panels, alerts, and ads pushing contentFixed player aspect box, overlays positioned over the player, panels with reserved height.
Dropped framesUnder 1 percentMain thread busy during decode on low-end devicesLower rung, pause emote animation, fewer DOM updates.
Memory over 6 hoursFlatChat history, alert queues, listenersRing 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 IntersectionObserver sees 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 button in 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.

MinutesWhat to doWhat to draw
0 to 5Ask 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 10Sketch the page: player, chat, channel bar, panels, left rail. Name which parts change how fast.The wireframe.
10 to 20The 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 28The 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 35Stream state machine and the real-time connection with topics.The state diagram and the topic router.
35 to 42Architecture, data model, API. The player in the app shell. Optimistic follow, confirmed subs.The component diagram.
42 to 45Performance 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?"

Think first

"A streamer with 500,000 viewers raids a small channel. What breaks?"

Think first

"Viewers say chat is ahead of the video by 10 seconds after a few hours. Why, and how would you know?"

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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next