cf.completefrontend
My progress
SYSTEM DESIGN · #84

Design a TikTok-style vertical video feed

An endless feed of short vertical videos where exactly one fills the screen and plays at a time, with likes, comments, and shares on top. The hard parts are deciding which video is active during fast flicks, starting each video in well under a second without wasting data, staying inside the phone's memory and decoder limits, and following the browser's autoplay rules.

The question

Design the mobile web client for a TikTok-style feed. The user opens the site on a phone and sees one vertical video that fills the screen and starts playing. They swipe up to get the next one, as fast as they like, and the next video should already be playing by the time their finger lifts.

On top of each video there are buttons to like, comment, share, and follow the creator. Walk me through how you'd build the scrolling, the playback, the preloading, and the engagement overlays, and tell me how you'd keep it smooth on a cheap Android phone on a 4G connection.

Real-life examples: TikTok For You feed on mobile web, Instagram Reels, YouTube Shorts, Snapchat Spotlight

Requirements: what to ask before drawing anything

A vertical video feed looks simple: a column of videos, one per screen. Most of the difficulty hides in questions the interviewer will not say out loud: how fast people swipe, what happens to sound, and how many videos a phone can hold in memory at once.

Spend the first five minutes asking questions whose answers change the design. When the interviewer shrugs, say out loud what you assume.

Go from the outside in: the surface (which screens), the users (devices and networks), the content (video length and size), then the rules (autoplay, data use, accessibility). The table shows the answers this guide assumes and what would move if the answer were different.

QuestionAnswer we assumeWhat it changes
Which surfaces?The For You feed on mobile web, plus its overlays: like, comments sheet, share, follow. Desktop works but is secondary.Touch gestures are the main input. Desktop adds wheel and keyboard.
Which devices?Phones from the last 5 years, many mid and low-end Android phones with 3 to 4 GB of memory, and iPhones with Safari.How many video elements we can keep alive and how much JavaScript runs during a swipe.
Which networks?From Wi-Fi down to a weak 4G signal of about 1.5 Mbps with 150 ms round trips.Rendition choice, how far ahead we preload, and data saver.
How long are videos?5 to 60 seconds, a median around 20 seconds, vertical 9:16, encoded at 480p, 720p, and 1080p.Short clips mean the first second matters more than the tenth minute.
How fast do people swipe?Many users skip a video in under 2 seconds, and flick through 3 or 4 videos in a row without stopping.Skipped videos must never start playing or download in full.
Sound on or off?The user expects sound, but the browser blocks unmuted autoplay until the user interacts.A muted start and a first-tap unmute pattern.
Is the feed personalized?Yes, ranked by a recommendation service. The order is not stable between requests.Cursor pagination with server-issued cursors and client-side deduplication.
Do we need search engine indexing?Only for single video pages (/video/ID) shared as links. The feed itself is personal.Server rendering for the share page, client rendering for the feed.
Wireframe of the feed on a phone. 1: the video fills the whole screen, poster first. 2: a column of actions on the right: follow, like, comment, share, with counts. 3: the author handle and a two line description at the bottom left. 4: a Tap for sound pill near the top. 5: a thin progress bar at the bottom. 6: the safe area above the home indicator, where nothing can be tapped. Notes explain each part.For You1 video4 sound23 caption51 poster, then videofills the screen, edge to edge2 follow, like, comment, sharepointer events only on buttons3 @handle and description4 pill until the first tap5 progress: one transform6 hatched: safe area inset6 home indicator zone
Everything except the video is an overlay. The overlay must let swipes pass through to the scroller, and its parts must stay clear of the safe area at the bottom.

Functional requirements

  • Show one video at a time, filling the screen. Swiping up shows the next, swiping down shows the previous.
  • The active video plays automatically and loops. All other videos are paused.
  • Tap toggles pause. The first tap on a muted feed turns sound on for the whole session.
  • Double-tap likes the video with a heart animation where the finger landed. A heart button also likes and unlikes.
  • A comments button opens a bottom sheet with its own scroll. The video keeps playing behind it.
  • Share copies a link or opens the system share sheet. Follow adds the creator.
  • The feed never ends: more videos load before the user reaches the last one.
  • Captions are available for every video, and a data saver setting lowers quality and preloading.

Non-functional requirements

  • Time to first frame on a cold load: under 1.5 seconds on 4G. The poster image shows within the Largest Contentful Paint budget of 2.5 seconds.
  • Time from swipe end to the next video playing: under 200 ms for a preloaded video, so it feels instant.
  • Swipes run at 60 frames per second on a mid-range phone: under about 10 ms of main thread work per frame while scrolling.
  • Memory stays flat over a 30 minute session: no more than 3 to 5 video elements alive at any time.
  • Data use: preload at most the next one or two videos, and never download a video the user flicked past in full.
  • Accessible: captions, keyboard control on desktop, screen reader labels for each video, and reduced motion respected.
  • Analytics are reliable: watch time is reported even when the user closes the tab mid-video.

Out of scope, and why

Video upload and editing (a separate flow with its own design, see the file uploader guide), live streams, direct messages, and the ranking model itself. The ranking model runs on the server: the client only needs to know that the order is personal and can change. Encoding and adaptive streaming formats are covered in depth in the YouTube guide, so this guide uses them and links there instead of re-teaching them.

Check your understanding · Exercise 1

Which of these answers would change the design the most? Select all that apply.

What makes a vertical video feed hard

A normal feed (Twitter, Instagram, a news site) is a list of cards. The browser renders them, the user scrolls, images load as they come close. A vertical video feed breaks almost every one of those habits. Five properties drive the whole design, and naming them early gives the interview a spine.

  1. One item at a time. Exactly one video fills the screen and only that one may play. A normal feed shows several cards and has no idea of a current card. Here the client must always know which video is active, and the answer changes many times a minute.
  2. Swipes are fast and impatient. People judge a clip in a second or two. They flick through several videos in a row, so the design must tell a video the user is watching apart from one the finger is passing through. Starting playback or a full download for every passing video wastes data and makes the one they stop on start late.
  3. Media is heavy and the browser guards it. A 20 second clip at 720p is roughly 2 to 4 MB. Browsers block unmuted autoplay until the user interacts, iPhones need special attributes to play inline, and every playing video holds a hardware decoder and tens of MB of memory.
  4. Phones have hard limits. A mid-range Android phone has a few hardware video decoders and a few GB of memory shared with every other app. Mount 50 video elements and the tab slows down, videos fail to start, or the browser reloads the page to reclaim memory.
  5. The order is personal and unstable. A recommendation service picks the next videos based on what the user just watched. Two requests for page 2 can return different videos, so offsets break and duplicates appear.

Here is what a fast flick looks like in time. The user is on video 4 and flicks hard. The list scrolls past videos 5 and 6 in about 300 ms and settles on video 7, and videos 5 and 6 are each the most visible item for about 100 ms.

If the code says "the video that is on screen plays", videos 5 and 6 both call play(), both start downloading, and both get paused a moment later. On a 1.5 Mbps link those wasted starts can delay video 7 by half a second or more.

Timeline of a fast flick from video 4 to video 7 in 50 millisecond steps, from 0 to 500 ms. Most visible: 4, 4, 5, 5, 6, 6, 7, 7, 7, 7. A naive player plays whatever is most visible, so it starts videos 5 and 6, each for 100 ms, marked bad. The settled active value stays 4 until the scroll ends at 400 ms, then becomes 7. Videos 5 and 6 never start.visiblenaivesettled4444445545546646647747747777770 ms100 ms200 ms300 ms400 msnaive: 5 and 6 startsettled: 7 at scrollend
The middle row is the bug: two wasted starts and two wasted downloads in 200 ms. The bottom row waits for the scroll to settle, so only the video the user stops on ever plays.

The rest of the guide answers those five properties one by one: how to show one video per screen (chapter 3), how to pick the active one (chapter 4), how to play it within the browser's rules (chapter 5), how to preload without waste (chapter 6), and how to stay within memory and decoder limits (chapter 7). Then come the feed API, the overlays, quality and data saver, the architecture, the data model, analytics, accessibility, and performance.

Think first

Why can't you take a normal infinite scroll feed (a virtualized list of cards with lazy images) and put a video in each card?

One video per screen: snapping, viewport units, and safe areas

The naive version is a plain column of full-height videos in a normal scrolling page. It fails in three ways within a minute of testing on a phone. A strong flick carries the list past five videos and lands halfway between two.

The browser's address bar shrinks while scrolling, so the page height changes and the videos jump. And on an iPhone with a home indicator, the like button sits under the system gesture bar. Each failure has its own fix.

Decision How should one swipe move exactly one video?

CSS scroll snap on a native scroller

The feed is an element with overflow-y: auto and scroll-snap-type: y mandatory. Each item is one screen tall with scroll-snap-align: start.

scroll-snap-stop: always on each item tells the browser not to skip past it, so even a hard flick stops at the next video.

Keyboard and wheel input get a little JavaScript that calls scrollTo with the next item's offset.

Pros

  • The browser runs the gesture and the momentum on the compositor thread, so swipes stay smooth even while JavaScript is busy.
  • Native scrolling keeps working with screen readers, switch control, find in page, and the browser's own keyboard handling.
  • Very little code: a few lines of CSS. Supported in every current browser, including scroll-snap-stop since 2022.

Cons

  • You don't control the physics: snap speed and the drag feel differ a little between Safari and Chrome.
  • Reacting to where the scroll will land is harder. You learn the result after it lands, not at the start of the gesture.
  • Mandatory snapping fights you when the content changes height, so every item must have a fixed height.

JavaScript swipe with transforms

The feed is a fixed track with touch-action: none. Pointer events track the finger and move the track with transform: translate3d(0, y, 0).

On release, the code looks at distance and velocity: past 25 percent of the screen or faster than about 0.5 pixels per ms means go to the next video, otherwise spring back.

A spring or eased animation moves the track to its final position.

Pros

  • Total control: you decide the next video at the moment the finger lifts, so playback and preloading can start about 300 ms earlier.
  • The same feel on every browser, and easy to cap a swipe at exactly one item.

Cons

  • The gesture runs on the main thread. A 100 ms task during a drag freezes the finger tracking, which users feel instantly.
  • You rebuild what the browser gives for free: momentum, rubber banding, wheel and trackpad, keyboard, and assistive technology support.
  • touch-action: none also turns off the browser's own gestures inside the feed, such as pull to refresh.

Free scrolling, snapped by JavaScript afterwards

A normal scroller without CSS snapping. When scrolling stops (a scrollend event or a 100 ms timer), the code scrolls smoothly to the nearest item.

Pros

  • Works in old browsers without snap support.
  • Simple to understand.

Cons

  • Two motions per swipe: the free scroll, then a visible correction. It feels broken.
  • A hard flick still travels many videos before the correction, which defeats one video per swipe.

What we pick: Use CSS scroll snap with scroll-snap-stop: always on a native scroller. Smooth gestures on a busy main thread matter more than custom physics, and native scrolling keeps accessibility for free. Add small JavaScript only for keyboard and wheel shortcuts. A JavaScript engine is worth its cost only when the product needs gesture effects that scrolling cannot express, such as a card that tilts as it follows the finger.

Comparison of one 100 millisecond main thread task during a swipe, with each box standing for about 33 ms. Top, CSS scroll snap: the screen lane shows eight boxes, all ok, while the main lane has a 100 ms task. Bottom, a JavaScript swipe: the main lane handles move events, then the same 100 ms task blocks three boxes, so the screen lane shows ok, ok, stuck, stuck, stuck, then a jump, then ok.CSS scroll snap: the browser moves the listscreenmainokokokokokokokok100 ms taskJavaScript swipe: the main thread moves itmainscreenmovemovemovemovemove100 ms taskokokstuckstuckstuckjumpokokeach box: about 33 ms of screen timetop: the task delays nothing the user seesbottom: the finger moves, the list does not
With native scrolling, the compositor keeps moving the list while the main thread is busy. With a JavaScript swipe, the finger moves but the list waits for the task to finish, then jumps.

The CSS, line by line

css
.feed {
  position: fixed;
  inset: 0;
  overflow-y: auto;
  scroll-snap-type: y mandatory;
  overscroll-behavior-y: contain; /* no pull-to-refresh or scroll chaining */
  scrollbar-width: none;
}

.feed-item {
  height: 100dvh;            /* fallback: height: 100vh; set first */
  scroll-snap-align: start;
  scroll-snap-stop: always;  /* a hard flick still stops at the next item */
  position: relative;
  contain: layout paint;     /* overlay changes stay inside the item */
}

.feed-item video {
  width: 100%;
  height: 100%;
  object-fit: cover;         /* fill the screen, crop the edges */
}

.actions {
  position: absolute;
  right: 12px;
  bottom: calc(16px + env(safe-area-inset-bottom));
}
The feed scroller. The page itself never scrolls, only this element does.

Why a fixed scroller instead of scrolling the page? On mobile Chrome and Safari, the address bar collapses when the root document scrolls. When only an inner element scrolls, the toolbars usually stay where they are, so the visible height stays stable and items don't change size in the middle of a swipe.

It also means overscroll-behavior-y: contain can stop pull to refresh from reloading the feed when the user swipes down on the first video.

Viewport units: 100vh, 100svh, and 100dvh

On phones, 100vh means the height of the viewport with the toolbars hidden: the largest possible value. When the toolbars are visible, a 100vh item is taller than the visible area, so its bottom (where the caption and buttons live) hides behind the browser's toolbar. The newer units fix this: 100svh is the small viewport (toolbars shown), 100lvh the large one (toolbars hidden), and 100dvh the dynamic one that follows the toolbars as they move. All three have worked in every major browser since late 2022.

For the feed, 100dvh is the right unit for item height because it equals what the user sees. Since the scroller is an inner element, the toolbars rarely move, so dvh rarely changes and there is no layout work during swipes. Keep height: 100vh on the line before as a fallback for old browsers. If the visible height does change (the user rotates the phone, or the keyboard opens for a comment), the browser re-snaps to the same item after layout, as the scroll snap spec asks browsers to do.

Three phones. Left, an item sized 100vh is taller than the visible area, so the like button sits below the browser toolbar and is hidden. Middle, an item sized 100dvh ends above the toolbar and the like button is visible. Right, with viewport-fit=cover the video fills the screen edge to edge, and hatched insets at the top for the notch and at the bottom for the home bar keep the like button clear.100vh100dvhviewport-fit=covertoolbarlikebuttons hiddentoolbarlikefits above toolbarnotchhome barlikepadded by insets
100vh uses the largest viewport, so the bottom of the item can hide under the toolbar. 100dvh matches what is visible. With viewport-fit=cover, pad controls by env(safe-area-inset-bottom).

Safe areas: notches and home indicators

Phones with a notch, a camera cutout, or a home indicator bar have areas where content can be drawn but can't be tapped reliably. Add viewport-fit=cover to the viewport meta tag so the video can paint edge to edge, then pad the interactive parts with env(safe-area-inset-top) and env(safe-area-inset-bottom). The video goes under the notch (it looks better), the buttons and captions stay clear of it. Without viewport-fit=cover, the insets are all 0 and the browser letterboxes the page instead.

Keyboard, wheel, and trackpad

With mandatory snapping, a mouse wheel notch scrolls a little and the browser then snaps to the nearest item, usually the next one, though the feel differs between browsers. Trackpads are the hard case: a long two finger swipe produces a stream of small wheel events with momentum, and scroll-snap-stop: always is what keeps it from skipping videos.

For the keyboard, don't rely on the browser's default arrow key step of about 40 pixels. Listen for ArrowDown and ArrowUp on the feed and scroll to the next item's offsetTop, with behavior: "smooth" normally and "auto" (instant) when the user prefers reduced motion. Chapter 14 covers the full keyboard map.

Check your understanding · Exercise 2

A tester reports that on an iPhone, a hard flick skips three videos and the like button sits under the home bar. Which changes fix it? Select all that apply.

Which video is active: observing, settling, and ignoring flicks

Everything else hangs off one value: the index of the active video. The player plays it, the preloader works out from it, analytics measures it, and the overlays bind to it.

The naive way to compute it is "the video that is visible right now". As chapter 2 showed, a fast flick makes two or three videos briefly visible, and each one would start playing and downloading for about 100 ms. So the question splits in two: which video is the candidate now, and when do we trust the candidate enough to play it?

Decision How should the client detect which video is on screen?

Scroll events and math

Listen to scroll on the feed and compute Math.round(scrollTop / itemHeight).

Pros

  • Exact and simple. Works in every browser.
  • One number, no per-item bookkeeping.

Cons

  • Runs on every scroll frame on the main thread. Cheap math is fine, but code tends to grow (analytics, preloading) and then every frame pays for it.
  • Reading scrollTop after writing styles in the same frame forces a layout.
  • Breaks if items ever stop being equal height, such as an ad or a sign-in card.

IntersectionObserver with thresholds

One observer with the feed as its root and threshold: [0, 0.25, 0.5, 0.75, 1] watches every mounted item.

The callback keeps a map of index to intersectionRatio. The candidate is the item with the highest ratio, if that ratio is at least 0.6.

Pros

  • The browser computes intersections as part of its own rendering work and calls back only when a threshold is crossed: a few times per swipe instead of 60 times per second.
  • Works with items of any height, and with the same code for the preload window (an item within one screen of the viewport).

Cons

  • Callbacks are asynchronous and batched, so the answer lags the pixels by a frame or two.
  • Only reports crossings: a value that never crosses a threshold is never reported, so thresholds must be chosen with care.

Scroll snap events

Newer Chromium browsers fire scrollsnapchanging while a gesture heads for a new snap target and scrollsnapchange once it lands, with the target element on the event.

Pros

  • Exactly the question we want answered: which item did the snap land on.
  • No thresholds to tune.

Cons

  • Chromium only (Chrome 129 and later) at the time of writing. Safari and Firefox users need another path anyway.
  • Still needs the settling logic below, because scrollsnapchanging can fire several times in one gesture as the predicted target changes.

What we pick: Use an IntersectionObserver to find the candidate, and commit the candidate as active only once it settles. It works in every browser, costs almost nothing per frame, and the same observer drives the preload window. Where scrollsnapchange exists, use it as an extra, earlier settle signal, but never as the only one.

Settling: candidate first, active later

The key idea is to keep two values. The candidate is whatever the observer says is most visible right now; it may change several times during a flick. The active video is the one we play; it changes only when the candidate has settled.

A candidate settles when the scroll has ended (the scrollend event, or a snap change event), or when it has stayed the candidate for 150 ms with no change. A video that is the candidate for 100 ms during a flick never becomes active, so it never calls play() and never starts a full download.

Why 150 ms? A snap animation after a normal swipe takes roughly 250 to 400 ms, while passing videos during a fast flick are the candidate for well under 150 ms each. A shorter delay lets passing videos through; a longer one makes a careful single swipe feel slow.

scrollend usually fires first on a normal swipe, so the timer is mostly a fallback for browsers without it. Safari added scrollend only in version 26.2 (late 2025), so feature-detect it with "onscrollend" in window.

ts
const ratios = new Map<number, number>();
let candidate = 0;
let settleTimer: number | undefined;

const io = new IntersectionObserver((entries) => {
  for (const e of entries) ratios.set(Number((e.target as HTMLElement).dataset.index), e.intersectionRatio);
  let best = candidate, bestRatio = 0;
  for (const [i, r] of ratios) if (r > bestRatio) { best = i; bestRatio = r; }
  if (bestRatio < 0.6 || best === candidate) return;
  candidate = best;
  clearTimeout(settleTimer);
  settleTimer = window.setTimeout(commit, 150); // fallback when scrollend is missing
}, { root: feedEl, threshold: [0, 0.25, 0.5, 0.75, 1] });

feedEl.addEventListener("scrollend", () => { clearTimeout(settleTimer); commit(); });

function commit() {
  if (candidate !== store.activeIndex) store.setActive(candidate); // play, preload, analytics react
}
Candidate from the observer, active after settling.

Try it in the lab below. Swipe slowly and each video becomes active after a short settle. Flick through several and watch the candidate race ahead while the active video stays put until the flick lands. The lab also shows which items are mounted, preloaded, and playing in a window of N around the active one, which chapter 7 explains.

Rule
Mounted window

Video 0 is active and playing. Try a slow swipe, then a flick, with each rule.

Active video lab, rule settled, window of 5. Video 0 is active and playing. Try a slow swipe, then a flick, with each rule. Mounted items: 0, 1, 2. play() calls 1, wasted starts 0, preloads started 2, aborted 0.items: empty means unmounted (window N = 5)IntersectionObserver ratio right now0play11seg2mant = 0 mscandidate: 0active: 0play() calls: 1wasted starts: 0preloads 2, aborted 0play: playing, seg: first segment, man: manifest, src: kept
Video 0 is active and playing. Try a slow swipe, then a flick, with each rule.

Choosing the threshold

Why 0.6 and not 1.0? With snapping, the landed item ends at a ratio very close to 1, but rounding of device pixels can leave it at 0.998, which never crosses a threshold of exactly 1. A ratio above 0.5 also guarantees one candidate at most, since two equal-height items can't both be more than half visible.

0.6 adds a margin against jitter around the halfway point. The extra thresholds at 0.25 and 0.75 keep the map fresh as items enter and leave, at no measurable cost.

One more edge case: the observer needs at least one callback after the very first render to know the starting candidate. It gives one: an observer always reports the initial state of each observed element. So the first video becomes the candidate immediately, and the settle timer commits it 150 ms later. For the first video only, skip the timer and commit at once, since there is no flick to filter and every millisecond of first play counts.

Check your understanding · Exercise 3

A user flicks from video 4 to video 7. Videos 5 and 6 are each the most visible item for about 100 ms. With the settling rule above, what happens? Select all that apply.

Playback rules: autoplay, sound, inline play, and hidden tabs

Once a video is active, it should play. The naive code is video.play() with sound on. On a fresh visit that call fails in every major browser: the returned promise rejects with a NotAllowedError, nothing plays, and if the code ignores the promise the user sees a frozen poster.

Browsers block audible autoplay on purpose, because pages that blast sound on load were one of the most hated parts of the web. The design has to work with these rules, not around them.

What the browsers allow

Autoplay rules at the time of writing (2026). Details move between versions, which is why the code must always handle a rejected play().
SituationChrome and EdgeSafari (iOS and macOS)Firefox
Muted autoplayAllowed.Allowed, but on iPhone only with playsinline: without it, video needs fullscreen and doesn't autoplay. Low Power Mode on iPhone blocks it.Allowed by default.
Autoplay with sound, first visitBlocked.Blocked.Blocked by default (a user setting).
Sound after the user tapped or clicked the pageAllowed: the page has user activation.Allowed when started from the gesture; later starts are less reliable (see below).Allowed after a user gesture.
Sound with no gesture this visitSometimes allowed: on desktop for sites where the user often plays media (the Media Engagement Index), and for sites added to the home screen or installed as an app.Blocked.Blocked unless the user allowed it for the site.
Video scrolled offscreenMuted autoplay videos may be paused by the browser.Same: invisible muted videos may be paused.Varies; don't rely on it either way.

Two rules come out of this table. First, always handle the promise from play(); the policy is the browser's decision and can change any day. Second, start muted, because muted inline playback is the only thing every browser allows on a first visit.

For iPhones, the playsinline attribute is required: without it, Safari plays video only in its own fullscreen player, so autoplay doesn't start. Set muted and playsinline as real attributes in the server HTML, not only as properties after hydration, because the browser decides about the first video before your scripts run.

Decision How should the feed handle sound?

Try sound, fall back to muted

Call play() unmuted. If it rejects with NotAllowedError, set muted = true and call play() again.

Pros

  • Returning Chrome users with high media engagement get sound with no tap.

Cons

  • On a first visit every video starts with a failed attempt, which costs a few frames and logs errors.
  • The behavior differs per browser and per user, so it is hard to test and to explain.

Start muted, the first tap unmutes for the session

Every video starts muted with a small "Tap for sound" pill. The first tap anywhere on the video unmutes it inside the tap handler and stores soundOn = true for the session.

Later videos start unmuted because the page now has user activation. If a play() ever rejects, fall back to muted and show the pill again.

Pros

  • Works the same in every browser on the first visit. Predictable to test.
  • The unmute happens inside a real gesture, which is what every browser requires.

Cons

  • One extra tap for people who wanted sound from the start.
  • The first tap must unmute, not pause, which conflicts with tap to pause. It's a deliberate exception for the first tap only.

A start gate

Show a full-screen "Tap to start" screen before the feed. The tap unlocks sound and starts the first video.

Pros

  • Sound from the very first video, on every browser.

Cons

  • Adds a step before any content: worse for first impressions and for links shared in chat.
  • The first video can't play behind the gate, so time to first frame starts counting after the tap.

What we pick: Start muted and let the first tap unmute for the whole session. It is the only option that plays content instantly on every browser and follows the rules instead of probing them. Keep the fallback from the first option inside it: if a later unmuted play() rejects, mute and retry rather than leaving a frozen video.

Sequence diagram between User, Tap layer, Store, Player, and Browser. The user taps. The tap layer tells the player muted equals false. The player calls play on the browser, which resolves because the call is inside a gesture. The tap layer sets soundOn to true in the store, and the store re-renders the overlay without the sound pill. On a later video the player calls play unmuted, the browser rejects with NotAllowedError, the player mutes and retries, and tells the store to show the pill again.UserTap layerStorePlayerBrowsertapmuted = falseplay()resolvessoundOnno pillplay(), laterNotAllowedmute, retryshow pill
The unmute must happen inside the tap handler, not after an await. The failure path at the bottom is what keeps a rejected play() from leaving a frozen video.

Safari needs one more detail. Chrome and Firefox remember user activation for the page: after a tap, media elements on the page may start with sound. Safari has historically been stricter: WebKit pauses a video that is unmuted without a user gesture, and ties the permission more closely to the element that played during the gesture.

The safe pattern for both is to keep a small pool of video elements that are reused for every item (chapter 7). Inside the first unmute tap, call play() on each pooled element once (the ones not on screen can pause right away), so every element that will ever play has been started from a gesture. This is cheap insurance; verify it on real iPhones before relying on it.

The player as a state machine

Each feed item's player moves through a small set of states. Writing them down stops a whole class of bugs, like a video that resumes playing after the user paused it, because scroll and visibility events don't know about the pause. The states are idle (no source), loading, ready (enough data for the first frame), playing, paused (by the user), buffering (the waiting event), blocked (play was rejected), and error. Two flags sit beside them: userPaused and soundOn.

State machine of one feed item's player. idle goes to loading when a source is set, loading goes to ready when data arrives, ready goes to playing when the item becomes active, and playing goes back to ready when it becomes inactive. ready goes to blocked when play is rejected, and blocked goes to playing on a tap. playing and paused switch on taps. playing goes to buffering on waiting and back on playing. error goes back to loading on retry. Notes: any state goes to error on a fatal error, and to idle when the source is released; the flags userPaused and soundOn sit beside the state.idleloadingreadyblockederrorbufferingplayingpausedsrcdatarejectedactiveinactivetaptaptapwaitingplayingretryfatal error: any state to errorsource released: back to idleflags beside the state:userPaused, soundOn
Only the drawn moves are allowed. Because userPaused is kept beside the state, becoming active again or the tab becoming visible never overrides a pause the user chose.
  • Becoming active moves ready to playing unless userPaused is set. Becoming inactive pauses and resets currentTime to 0, so coming back starts the clip from the start, a common choice for short looping clips.
  • Tab hidden (document.visibilitychange with document.visibilityState === "hidden") pauses the active video and remembers that the system paused it, not the user. Becoming visible resumes it only in that case.
  • Losing focus without being hidden (a desktop user clicks into another window) keeps playing. Pausing on blur annoys people watching while typing elsewhere.
  • Looping: set the loop attribute. Note that a looping video never fires ended, so count loops by watching currentTime jump back near 0 in timeupdate (chapter 13).
  • Blocked shows a play button. A tap on it is a gesture, so it can start the video, with sound if soundOn is set.

The playback lab below lets you try these rules. Switch between a fresh visit and after a tap, muted and unmuted, tab visible and hidden, and see what play() returns and what the user sees.

Browser
Gesture
Sound
Tab

Chrome, Android, no tap yet, muted, tab visible. play() resolves. The user sees the video plays without sound. Our code shows the Tap for sound pill.

Playback policy lab. Chrome, Android, no tap yet, muted, tab visible. play() resolves. The user sees the video plays without sound. Our code shows the Tap for sound pill.play() returns:resolvesthe user sees:the video plays without soundour code:shows the Tap for sound pill
Chrome, Android, no tap yet, muted, tab visible. play() resolves. The user sees the video plays without sound. Our code shows the Tap for sound pill.

Check your understanding · Exercise 4

A user opens a shared link on an iPhone and sees a frozen poster with no play button. Which bugs could cause this? Select all that apply.

Preloading: the next video ready before the swipe ends

The requirement says the next video should play within 200 ms of the swipe ending. On a 4G link with 150 ms round trips, a cold start needs a DNS and connection setup (often already done), the manifest, the first media segment, and decoding: easily 600 ms to 1.5 s. The only way to hit 200 ms is to have the first second or two of the next video already downloaded. The question is how much, for which videos, and how to stop when the user flicks away.

The two naive extremes both fail. Preloading nothing gives a black frame and a spinner on every swipe.

Setting preload="auto" on every mounted video asks for several whole clips at once: five 3 MB clips is 15 MB, all competing with the video the user is actually watching. On a 1.5 Mbps link that is 80 seconds of download, so the active video stalls and data plans drain. The right answer is a small, ordered budget.

Decision How should the next videos be preloaded?

The preload attribute

Give the next video its src and preload="auto", the one after preload="metadata", and the rest preload="none".

Pros

  • No extra code or libraries. Works with plain MP4 files.
  • The browser's media cache handles everything.

Cons

  • It is only a hint, and mobile browsers often load less. Chrome has treated auto as metadata on cellular connections, and iOS Safari often loads nothing until playback starts.
  • No control over how many bytes: auto may fetch the whole file. No clean way to say "just the first 2 seconds".
  • Each preloading element holds a decoder and memory as soon as it has a source (chapter 7).

First segments through Media Source Extensions

Videos are packaged as HLS or DASH with 2 second segments. A preloader fetches the manifest and the first one or two segments of the next videos with fetch and an AbortController.

When a video becomes active, the player (hls.js, dash.js, or your own code on MediaSource or ManagedMediaSource) appends the cached segments first, then streams the rest.

Pros

  • Exact control: about 150 to 300 KB per preloaded video, a rendition chosen for the network, and instant cancel.
  • No video element is needed to preload, so no decoder is held until the video is close to playing.

Cons

  • More code and a streaming library of roughly 100 KB or more, gzipped.
  • iPhones gained Managed Media Source only with iOS 17.1, and it needs disableRemotePlayback on the element or an HLS source for AirPlay. Older iPhones need native HLS, where you can't feed cached segments.

Range requests into the HTTP cache

fetch the first 500 KB of each MP4 with a Range header so the bytes land in the HTTP cache, and hope the video element reuses them.

Pros

  • Small code. Warms the CDN edge as well as the browser.

Cons

  • Whether the media pipeline reuses those cached bytes varies by browser and by cache headers. You can't rely on it.
  • Byte offsets don't map cleanly to seconds, especially when the MP4 index (moov) is at the end of the file.

What we pick: Use first segments through Media Source Extensions where available, because the feed's whole speed depends on controlling exactly which bytes download and when. Fall back to native HLS on older iPhones: give the next element its source early and accept that Safari may load little before playback, so the poster carries the first paint. Encode segments short (2 seconds) so the first one is small, and keep the MP4 fallback files with their index at the front ("fast start").

The budget: how far ahead, and in what order

Think of preloading as a ladder around the active video at index i, where each rung costs less than the one before. The active video streams normally. Video i + 1 gets its manifest and first segment, about 2 seconds of video.

Video i + 2 gets only its manifest and poster image. Video i - 1 keeps what it already has, because swiping back is common. Everything further away gets nothing new.

The preload ladder around the active video i. i minus 2: nothing new. i minus 1: keeps its buffer. i: streams normally while playing. i plus 1: first segment, about 2 seconds or 300 KB. i plus 2: manifest and poster, about 50 KB. i plus 3: nothing. Notes: the active video's buffer comes first, then i plus 1, then i plus 2; when the active index changes, anything that drops to none is aborted. In data saver mode, i plus 1 gets only its manifest and i plus 2 nothing.i-2none0i-1keepbufferheldistreamplayingongoingi+1segmentfirst 2 s300 KBi+2manifest+ poster50 KBi+3none0order: i, then i+1, then i+2window moves: abort the restdata saver: i+1 manifest,i+2 nothing at all
Each rung costs less than the one before. The whole budget moves with the active index, and the preloads it leaves behind are aborted, not left to finish.

Order matters as much as amount. The active video's buffer comes first: preloading starts only once the active video can play through, or has about 5 seconds buffered. Then comes i + 1, then i + 2.

On a 1.5 Mbps link (about 190 KB per second), a 2 second first segment at 720p and 1.2 Mbps is 300 KB, about 1.6 seconds of download. At 480p and 600 Kbps it is 150 KB, under a second. Since people often watch a clip for 5 to 15 seconds, there is usually time for both; chapter 10 picks the rendition.

ts
type Plan = { id: string; level: "stream" | "first-segment" | "manifest" | "keep" | "none" };

function planPreload(items: FeedItem[], active: number, saver: boolean): Plan[] {
  return items.map((item, i) => {
    const d = i - active;
    if (d === 0) return { id: item.id, level: "stream" };
    if (d === 1) return { id: item.id, level: saver ? "manifest" : "first-segment" };
    if (d === 2) return { id: item.id, level: saver ? "none" : "manifest" };
    if (d === -1) return { id: item.id, level: "keep" };
    return { id: item.id, level: "none" };
  });
}

// The preloader diffs the new plan against running work:
// start what is new, abort() what fell to "none", leave the rest alone.
The preload planner reruns whenever the active index or network changes.

Posters: something real on screen instantly

Even a preloaded video takes a frame or two to decode. A poster image covers that gap, and for the very first video it covers the whole load.

Each feed item in the API carries a poster URL (the first frame, around 540 by 960 pixels in AVIF or WebP, about 30 to 50 KB) and a tiny blurred placeholder of a few hundred bytes inlined in the response. The blur paints with the first HTML, the poster replaces it a moment later, and the video replaces the poster on its first frame.

For the first video, the poster is the Largest Contentful Paint element on most phones. Render it in the server HTML, preload it with link rel="preload" as="image" fetchpriority="high", and serve it from the same CDN as the page so no new connection is needed. The image optimization lesson covers formats and responsive sizes in depth, and the Netflix guide covers artwork pipelines; here the point is only that the poster is the feed's first paint.

Cancelling on fast scroll

Every preload is started with its own AbortController. When the plan changes, anything that dropped to none is aborted at once: the fetch stops, the bytes stop counting against the data plan, and the bandwidth goes to the new neighbors.

Plans change only when the active index changes, which (thanks to settling in chapter 4) doesn't happen for the videos a flick passes through. So a flick from 4 to 7 makes one plan change: it aborts the unfinished preload for 5, keeps what 6 already has (6 is now the previous video), and starts 8 and 9.

Check your understanding · Exercise 5

On a 1.5 Mbps connection the active video keeps stalling right after each swipe. Which changes help? Select all that apply.

Memory and decoder limits: a small window of recycled elements

A user who scrolls for 30 minutes passes 150 to 300 videos. The naive feed appends one item, with its own video element, per video. Somewhere between video 20 and video 60 on a mid-range phone, things start to go wrong: new videos show a black frame, playback stutters, the tab reloads itself (the browser killed it to free memory), or on iPhones some videos refuse to play at all. None of this shows on a developer's laptop, which is why it ships.

What one video element costs

Estimates for a 720p clip. Exact numbers vary by device, codec, and browser, which is why the design caps counts rather than bytes.
CostRough sizeWhen it is paid
The DOM element and its listenersKilobytesAs soon as it exists. Cheap.
The media buffer (downloaded, not yet played data)1 to 10 MB for a short clip; Media Source buffers can grow to tens of MB before the browser evicts old dataOnce it has a source and starts loading.
Decoded frames: one 720 by 1280 frame is about 1.4 MB, and a decoder keeps severalRoughly 10 to 30 MB per decoding videoWhile it decodes: playing, or preparing the first frame.
A hardware decoder slotPhones have only a few; when they run out the browser falls back to slow software decoding or failsWhile it has a loaded source, depending on browser.

The pattern is that an element is cheap, but an element with a source is expensive. Browsers also cap how many media players a page may create: Chromium, for example, refuses new players past a few dozen per frame, with a lower cap on Android, and logs a "too many WebMediaPlayers" warning.

Such caps are not in any spec, change between versions, and are separate from the smaller number of hardware decoders. Developers also report iOS Safari refusing to start more videos past a count that varies by device and iOS version. So design for a small, fixed count (3 sources at once) and test on the oldest phone you support.

Decision How many video elements should exist, and how are they managed?

One element per item, never removed

Each feed item renders its own video. Items stay in the DOM as the user scrolls.

Pros

  • Simplest code. Swiping back is instant because everything is still there.

Cons

  • Memory and decoder use grow with the session until the phone gives up.
  • Thousands of DOM nodes make style and layout slower for every overlay change.

A window of mounted items, new elements each time

Render only items i - 2 to i + 2, inside a container as tall as the whole list. Items entering the window mount a fresh video; items leaving unmount theirs.

Pros

  • Bounded memory: at most 5 elements, at most 3 with a source.
  • Easy to write with any virtual list approach.

Cons

  • Creating and destroying media elements on every swipe costs main thread time right when the swipe settles.
  • A fresh element has never played during a user gesture, which matters for sound on Safari (chapter 5).
  • Forgetting to release a source before unmount can leave the decoder busy until garbage collection runs.

A window with recycled elements

Render 5 item shells keyed by index % 5, each with one video that is created once and never destroyed. As the window moves, the shell that leaves at one end is reused for the item entering at the other end.

Only shells at distance 1 or less from the active item get a source. The others keep an empty element and show the poster.

Pros

  • Bounded memory and no element creation during swipes.
  • The same 5 elements were all touched during the first unmute tap, so sound keeps working on Safari.

Cons

  • The release and reuse steps must be exact, or one video's last frame flashes on another's item.
  • More careful code: keys, positions, and sources all move independently.

What we pick: Recycle a fixed window of 5 elements, with sources on at most 3: previous, active, and next. It bounds memory and decoders on every phone, avoids creating media elements during the most sensitive moment of the swipe, and keeps the gesture-unlocked elements alive for sound. The extra care in release and reuse is a small, testable piece of code.

Five shells, slots 0 to 4, each showing the item whose index modulo 5 equals the slot. With video 6 active, the slots hold items 5, 6, 7, 8, and 4; items 5, 6, and 7 have a source and 6 is playing. After a swipe to video 7, the slots hold 5, 6, 7, 8, and 9: slot 0 releases item 5's source, slot 3 gives item 8 a source, slot 4 is reused for item 9 instead of item 4, and 7 is playing.slotactive 6active 7055166277388449orange: playinggreen: has a sourceslot 0: source releasedslot 4: reused, 4 to 9
No element is created or destroyed on the swipe. One slot drops its source, one gains a source, and one is handed to a new item at the far end of the window.

Recycling without moving DOM nodes

Removing a media element from the document pauses it, and moving it counts as removing and inserting. So recycled shells must never move in the DOM.

The trick is to give the shells absolute positions in a container as tall as the whole list (items.length times the screen height), and to render them in slot order, not item order. React sees the same 5 keys in the same order on every render, so it never moves a node; only each shell's top and its item data change. Scroll snap still works because snap areas can be anywhere inside the scroller.

tsx
const SLOTS = 5;

function FeedWindow({ items, active }: { items: FeedItem[]; active: number }) {
  const first = Math.max(0, active - 2);
  const slots = Array.from({ length: SLOTS }, (_, s) => {
    // the one index in [first, first + 4] with index % 5 === s
    const index = first + ((s - (first % SLOTS) + SLOTS) % SLOTS);
    return { slot: s, index, item: items[index] };
  });
  return (
    <div className="feed" style={{ height: items.length * viewportHeight }}>
      {slots.map(({ slot, index, item }) => (
        <FeedShell key={slot} index={index} item={item}
          hasSource={Math.abs(index - active) <= 1} isActive={index === active} />
      ))}
    </div>
  );
}
Five shells in slot order. Slot s shows the item whose index modulo 5 is s.

Releasing a source properly

Pausing a video doesn't free anything. To release its buffer and decoder, a shell that loses its source runs four steps: video.pause(), video.removeAttribute("src") (or detach the streaming library, such as hls.detachMedia()), video.load() so the element resets to an empty state, and URL.revokeObjectURL() for any MediaSource URL it created.

The HTML standard recommends this remove-then-load step as the way to stop a media element's network and decoding work. Then the shell swaps the poster to the new item's poster before the new source is set, so the old video's last frame never shows on the new item.

Beyond the 5 shells, the feed data itself also grows: 300 items of metadata are small (a few hundred KB), so keep them all for instant swiping back. But drop prefetched segments for items more than one step away, and cap the comments cache. The memory leaks lesson shows how to confirm this with heap snapshots: the number of HTMLVideoElement objects should stay at 5 no matter how long the session.

Check your understanding · Exercise 6

After 10 minutes of scrolling, an Android phone starts showing black frames for new videos. Heap snapshots show 5 video elements, as designed. What could still be wrong? Select all that apply.

The feed API: cursors, an unstable order, and never running out

The feed has no end and no fixed order. A recommendation service picks each batch from what the user just watched, liked, and skipped.

That breaks the naive GET /feed?page=2&limit=10: by the time page 2 is requested, the ranking has changed, so page 2 can repeat videos from page 1 or skip ones that moved. Offset pagination assumes a list that holds still, and this one never does. The pagination lesson covers offsets and cursors in general; this chapter is about what the For You feed adds.

Decision How should the client ask for more videos?

Offset pages

GET /feed?offset=20&limit=10. The server ranks and slices.

Pros

  • Simple, cacheable, easy to debug.

Cons

  • The ranking changes between calls, so duplicates and gaps are guaranteed.
  • The server has to rank everything up to the offset again on every call.

Opaque cursor from the server

The server returns nextCursor with each page: an opaque token that encodes the feed session and what was already served. The client sends it back unchanged.

Pros

  • The server stays free to rerank between pages, knowing exactly what this client has.
  • The client can't build cursors, so the format can change without a client release.

Cons

  • No jumping to an arbitrary page. Not a problem for a feed.
  • A cursor can expire (a server restart, a long pause); the client must handle that.

Server-held feed session

The first call creates a session with an id; later calls send the id, and the server keeps the queue of ranked videos in memory.

Pros

  • The server can pre-rank a long queue and serve batches fast.

Cons

  • Server state per user to store, expire, and route to. A deploy or failover loses it.
  • Two tabs share or fight over one session.

What we pick: Use an opaque cursor. It gives the ranking service the freedom it needs, keeps the client simple, and the server can still put a session id inside the token if it wants a pre-ranked queue. The client adds one safety net of its own: it deduplicates by video id, because no server is perfect.

ts
// GET /api/feed?cursor=eyJzIjoi...&limit=10
type FeedResponse = {
  items: FeedItem[];
  nextCursor: string | null;   // null only if the feed truly ends (rare)
};

type FeedItem = {
  id: string;
  author: { id: string; handle: string; avatarUrl: string; followed: boolean };
  description: string;
  durationMs: number;
  posterUrl: string;           // first frame, about 540 x 960
  blurDataUrl: string;         // a few hundred bytes, painted instantly
  streams: { hls: string; dash: string; mp4Fallback: string };
  captions: { lang: string; url: string; auto: boolean }[];
  stats: { likes: number; comments: number; shares: number };
  viewer: { liked: boolean };
};
The feed endpoint. Ten items is about 20 to 30 KB of JSON.

Deduplication

The client keeps a Set of every video id in the feed. When a page arrives, it drops items whose id is already there before appending. It also sends the ids of videos the user actually watched in the analytics stream (chapter 13), which the server folds into the session so it stops recommending them. Deduplication runs before anything is appended, never after, so the window of shells and the active index never shift under the user.

Fetching the next page before it is needed

Trigger the next page request when 3 unwatched items remain after the active one. Why 3? A fast flicker spends about 2 seconds per video, so 3 videos give 6 seconds, while a feed API call on 4G takes 300 ms to 1.5 s and the first video of the new page still needs its first segment preloaded.

So 3 covers the fast user with room for one slow request. A lower number risks a spinner, and a higher one wastes requests on users who leave soon. Keep at most one request in flight with a simple flag; a second trigger while one is pending does nothing.

Sequence diagram between Scroller, Feed store, API client, and Server. The scroller settles on item 17 and writes the active index. The feed store sees only 3 items left and asks the API client for the next page. The API client sends GET /api/feed with cursor c2. The server replies with 10 items and cursor c3. The API client hands the page to the feed store, which drops 1 duplicate id and appends 9 new items to the scroller.ScrollerFeed storeAPI clientServeractive = 173 left: next pageGET cursor=c210 items, c3pageappend 9 new1 duplicate id dropped first
The request starts while the user still has 3 videos to watch, so the new items arrive long before they are needed. Deduplication happens before anything is appended, so the active item never moves.
  • Expired cursor: the server replies 410 Gone with a fresh cursor or a hint to restart. The client fetches a new page without a cursor and appends it after deduplication; the user sees nothing.
  • Network error: retry with backoff (1 s, 2 s, 4 s). If the user reaches the last item before it succeeds, show a full-screen "Couldn't load more" item with a retry button, as a real feed item that snaps like the others.
  • First page: the server renders the first item into the HTML and inlines the first page as JSON, so the first video needs no API call at all. The share page /video/ID does the same with that video first.
  • Going back to the feed: keep the items and active index in memory (and in sessionStorage for a reload), so returning from a profile lands on the same video instead of a new ranking.

Check your understanding · Exercise 7

A user sees the same video twice within a minute. Which parts of the design should prevent it? Select all that apply.

Engagement overlays: likes, comments, share, and follow without blocking the video

On top of each video sit the creator's handle and description at the bottom left, and a column of buttons on the right: the avatar with a follow plus, like, comment, share. They look like decoration, but they create three real problems.

They must not steal the swipe gesture. A tap on the video must be told apart from a double tap. And every action must feel instant on a slow network.

Letting swipes pass through

Because the feed uses native scrolling, a vertical swipe that starts on a button still scrolls the feed, as long as nothing calls preventDefault() on touch events. Keep every touch listener passive and never set touch-action: none inside the feed.

The overlay container covers the whole item but has pointer-events: none; only the buttons and the caption get pointer-events: auto, so a tap on empty overlay space reaches the video's tap layer underneath. Set touch-action: manipulation on the item: it keeps scrolling and pinch zoom but turns off double-tap to zoom, which would otherwise fight the double-tap like.

Decision How do you tell a single tap (pause) from a double tap (like)?

Wait, then act

On the first tap, start a 250 ms timer. A second tap inside it is a double tap: like, and cancel the timer. If the timer fires, it was a single tap: pause.

Pros

  • Each gesture does exactly one thing. The video never stutters on a like.

Cons

  • Pausing lags the finger by 250 ms.

Act, then undo

Pause on the first tap at once. If a second tap arrives within 250 ms, resume and like.

Pros

  • Pause feels instant.

Cons

  • Every like makes the video pause and resume for a quarter second: a visible hiccup, and a fake pause event in analytics.

Separate zones

Only a play button pauses; any double tap anywhere likes.

Pros

  • No timing at all.

Cons

  • In TikTok and YouTube Shorts a tap anywhere pauses, and Instagram Reels moved to the same gesture in 2026. Breaking that habit feels wrong.

What we pick: Wait, then act, with a 250 ms window. A short pause delay is barely noticed, while a video that hiccups on every like is. Run the heart animation at the second tap's coordinates right away, so the like itself has no delay at all.

The heart is a small absolutely positioned element placed at the tap's clientX and clientY, animated with transform and opacity only, so the compositor runs it without layout or paint. It removes itself on animationend. A double tap only ever likes; it never unlikes, even if the video is already liked. That's how the big short video apps generally behave, and it means a happy user tapping repeatedly can't undo their own like by accident.

Optimistic likes and rapid toggles

A like should flip the heart red and bump the count in the same frame as the tap; waiting 300 ms for the server would feel broken. So update the store first, send the request, and roll back only on failure, as the optimistic updates lesson describes. Two details matter for a feed.

First, send the desired state, not a toggle: PUT /api/videos/ID/like to like and DELETE to unlike. A retried toggle could flip the state twice; a retried PUT can't.

Second, users tap like, unlike, like in a second. Keep one in-flight request per video and, when it returns, send the latest state only if it differs from what the server has. Three taps become at most two requests, and the final state always wins.

Sequence diagram between User, Like button, Entity store, and API. The user double taps. The like button calls like on video 42 in the entity store, which re-renders the heart red with the count plus one at once. The store sends PUT /like and the API answers 204. Failure path: for another video the store sends PUT /like and the API answers 503. The store rolls back, the heart re-renders grey with the old count, and the user sees a toast saying the like failed.UserLike buttonEntity storeAPIdouble taplike(v42)red, +1PUT /like204failure pathPUT /like (v43)503roll backtoast
The heart turns red before the request leaves. On failure, the store restores exactly the state it saved, and a toast says what happened.

The comments sheet

Comments open in a bottom sheet covering about 70 percent of the screen, with the video still playing in the strip above it. The sheet has its own scroller with overscroll-behavior: contain, so reaching the end of the comments doesn't scroll the feed behind it.

While the sheet is open, the feed must not respond to swipes at all: set overflow: hidden on the feed scroller (it keeps its scroll position) and mark the feed inert so taps and screen readers skip it. Threading, sorting, and posting are the comments thread guide's subject; here the sheet only loads the first page of comments when it opens, never before.

  • Focus: opening moves focus to the sheet's heading; Escape and the close button return focus to the comment button that opened it.
  • Back button: opening pushes a history entry (?comments=1), so the Android back gesture closes the sheet instead of leaving the feed.
  • Drag to close: dragging the handle down past 30 percent of the sheet's height closes it. Only the handle drags; the list inside scrolls normally.
  • Keyboard on mobile: typing a comment opens the on-screen keyboard. Size the sheet in dvh and keep the input above the keyboard with the visual viewport size, so it isn't hidden.

Share and follow

Share calls navigator.share() with the video's canonical URL where it exists (mobile Safari and Chrome on Android support it), and falls back to navigator.clipboard.writeText() with a "Link copied" toast. Both can require a recent user gesture, so call them directly in the click handler, not after an await. Follow is optimistic like a like, but it changes the author, not the video: every feed item by that creator must update at once, which is why authors live in their own normalized table (chapter 12).

Check your understanding · Exercise 8

After adding the overlay, users report that swipes starting on the right side of the screen don't scroll. What could cause it? Select all that apply.

Adaptive quality and data saver

Each video is encoded at a few renditions, for example 480p at about 600 Kbps, 720p at about 1.2 Mbps, and 1080p at about 2.5 Mbps. H.264 gives reach, and HEVC or AV1 save bytes where the device decodes them in hardware. The YouTube guide explains adaptive bitrate streaming in depth: the player measures throughput and switches renditions at segment boundaries.

Short clips change the problem in one important way. A 20 second clip has only 10 segments of 2 seconds, and the user decides in the first one or two. An algorithm that starts low and climbs, which works for a 10 minute video, spends most of a short clip at the wrong quality, so the starting rendition is the decision that matters.

Decision How should the player pick the starting rendition for each video?

One fixed rendition

Always start at 720p and let the adaptive logic adjust later.

Pros

  • Simple and predictable.

Cons

  • Too heavy for weak 4G: the first segment takes 2 seconds or more and the first frame is late.
  • Wasteful on a small screen, and blurry on a large tablet.

The Network Information API

Read navigator.connection.effectiveType (like "3g" or "4g") and downlink, and map them to a rendition.

Pros

  • Available before any video downloads, so it helps the very first one.

Cons

  • Only Chromium browsers implement it. Safari and Firefox don't expose navigator.connection, so iPhones get no benefit.
  • The values are coarse, rounded, and based on recent traffic of the whole browser, not your CDN.

Measured throughput, capped by device and settings

The preloader times every segment it downloads and keeps a smoothed estimate of throughput. The next video starts at the highest rendition that fits in about 70 percent of it.

Caps apply on top: no rendition wider than the screen's physical pixels (a 390 pixel wide phone at 2x gets at most 720p), 480p in data saver mode, and lower while Save-Data: on is present.

Pros

  • Measures the real path to your CDN, on every browser.
  • Preloading gives free measurements: by the time video 2 starts, several segments have been timed.

Cons

  • The very first video has no measurement. It needs a guess (the Network Information API where present, otherwise 480p).
  • Throughput on phones jumps around, so the estimate must be smoothed and still treated with care.

What we pick: Use measured throughput with device and setting caps, and use the Network Information API only as the first guess where it exists. The feed's own preloading produces a steady stream of real measurements, which beat any browser estimate, and the caps keep the choice honest about screen size and the user's wishes.

ts
const LADDER = [
  { short: 480, bps: 600_000 },     // "480p": the short side, the width of a vertical video
  { short: 720, bps: 1_200_000 },
  { short: 1080, bps: 2_500_000 },
];

function startRendition(throughput: number | null, screenWidthPx: number, saver: boolean) {
  const firstGuess = guessFromConnection() ?? 600_000;   // Chromium only, else 480p
  const budget = (throughput ?? firstGuess) * 0.7;        // leave headroom for preloads
  const maxShort = saver ? 480 : screenWidthPx;            // innerWidth * devicePixelRatio
  const fits = LADDER.filter((r) => r.bps <= budget && r.short <= maxShort);
  return fits.at(-1) ?? LADDER[0];
}

function guessFromConnection(): number | null {
  const c = (navigator as { connection?: { downlink?: number } }).connection;
  return c?.downlink ? c.downlink * 1_000_000 : null;      // downlink is in Mbps
}
Choosing the starting rendition. Throughput is a smoothed estimate in bits per second.

Data saver: what it turns off

Some users pay per GB. A 30 minute session at 720p with full preloading can use 300 MB or more, which on a prepaid plan is real money. So offer a Data saver setting and explain what it does.

It caps playback at 480p, preloads only the manifest for the next video (no media segments), skips preloading the one after, and loads smaller posters. The cost is a short spinner on some swipes, a trade the users who choose it accept.

Two signals can turn it on automatically or suggest it. The Save-Data: on request header (and navigator.connection.saveData in Chromium) report a browser or system data saving mode. Few browsers send it since Chrome retired its Lite mode in 2022, so treat it as a bonus when present, and read it on the server to send a lighter first page.

The prefers-reduced-data media query has been proposed, but no stable browser ships it. The user's own setting is the signal that always works: store it in localStorage and send it with feed requests so the server can choose smaller posters too.

Check your understanding · Exercise 9

An iPhone user on a weak connection always gets a slow first frame, while Android users on the same network don't. Why could that be? Select all that apply.

Architecture: the pieces and why the boundaries sit where they do

With the hard decisions made, the architecture almost draws itself. The one big rule is to keep media out of React's render loop. A playing video fires timeupdate several times a second (every 15 to 250 ms, by the HTML standard), progress while downloading, and waiting and playing on every stall.

If each of those went through component state, every overlay would re-render several times a second on a phone that is busy decoding video. So the media side is a set of plain TypeScript controllers that own the video elements. React renders only discrete facts: which item is active, whether it is playing, paused, or blocked, and whether sound is on.

Component architecture. Inside the browser, left column: Feed scroller, Active tracker, Stores, API client. Right column: Overlays, Playback, Preloader, Analytics. The feed scroller sends IntersectionObserver entries to the active tracker. The tracker sends the active index to Playback and to the Stores. The Preloader hands segments to Playback and fetches them from the Video CDN outside the browser. Analytics sends beacons to the Collector. The Stores fetch through the API client, which talks to the API or BFF below the browser.BrowserFeed scrollerOverlaysActive trackerPlaybackStoresPreloaderAPI clientAnalyticsVideo CDNCollectorAPI / BFFentriesactiveindexsegmentsGETbeaconfetchJSON
The active tracker is the hinge: it writes one value, and playback, preloading, pagination, and analytics react to it. Media bytes go from the CDN to the player without passing through the stores.
PieceResponsibilityWhy it is separate
Feed page and scrollerRenders the 5 shells, the snap container, and the overlays. Reads the store.The only React-heavy part. Knows nothing about bytes or decoders.
Active trackerRuns the IntersectionObserver and the settle rule, and writes activeIndex.One clear owner of the most important value. Easy to test with fake entries.
Playback controllerOne state machine per shell. Applies autoplay, sound, visibility, and looping rules.Media events stay here instead of in React state.
PreloaderTurns the active index into a plan, fetches manifests and first segments, aborts on change, measures throughput.Network policy in one place, shared by playback and quality choice.
Stream adapterFeeds segments to MediaSource or ManagedMediaSource (often through hls.js), or sets a native HLS or MP4 source.Hides browser differences behind one attach, detach interface.
Feed storeItems in order, the cursor, the dedupe set, loading and error state.Server data, cached and paginated: a query cache handles it well.
Entity storeNormalized videos, authors, and the viewer's likes and follows, with optimistic updates.A follow or like must update every place that shows that author or video.
Comments sheetLazy loaded on first open: thread list, composer, its own query cache entries.Most sessions never open it, so its code stays out of the first load.
Analytics trackerListens to playback events, builds watch sessions, batches and sends them.Must keep working while the page hides or closes.
SettingsData saver and caption preferences, in localStorage, plus session sound state.Read by the preloader, quality picker, and player.

The active index is the hinge: the active tracker writes it, and everything else reacts to it. The playback controller plays the new active shell and pauses the old one, and the preloader recomputes its plan.

The feed store checks whether 3 or fewer items remain and fetches the next page. The analytics tracker closes the previous watch session and opens a new one. Because all of these subscribe to one value, adding a new reaction (say, prefetching the next creator's avatar) never touches the scroller.

How data moves end to end

Data moves in four lanes with very different shapes. Metadata is small JSON from the API (about 2 to 3 KB per item), cached in the feed and entity stores. Media is large binary data from the video CDN (hundreds of KB per segment); it never enters a store, and goes from the preloader's short-lived segment cache straight into the stream adapter.

Writes are small API calls for likes, follows, and comments, applied optimistically. Analytics are batched events sent to a separate collector endpoint, so they never queue behind user actions.

Data flow in four lanes. Metadata: Feed API sends a JSON page to the API client, which passes items to the Feed store, which passes ids to the feed shells. Media: the Video CDN sends segments to the Preloader, which passes buffers to the MSE adapter, which appends them to the video element. Writes: the Like button taps into the Entity store, which sends a PUT through the API client to the Like API. Analytics: Playback sends events to the Tracker, which builds sessions into a Queue, which sends a beacon to the Collector.metadata: small, cachedFeed APIAPI clientFeed storeFeed shellsJSON pageitemsidsmedia: large, never in a storeVideo CDNPreloaderMSE adaptervideo elementsegmentsbuffersappendwrites: optimistic firstLike buttonEntity storeAPI clientLike APItapPUTrequestanalytics: batchedPlaybackTrackerQueueCollectoreventssessionsbeacon
Four lanes with four shapes: small cached JSON, large short-lived media, small optimistic writes, and batched analytics. Keeping them apart is what lets each be tuned on its own.
  • Page load: the server sends HTML with the first item's poster and inlined first page JSON. The client hydrates, the active tracker commits item 0 at once, and the preloader starts its manifest and first segment.
  • Swipe: the active tracker settles on a new index. The playback controller plays it from the preloaded segment. The preloader aborts what fell out of the window and starts the new neighbors.
  • Near the end: the feed store requests the next page with the cursor, deduplicates, and appends. The scroller grows by 10 items of height.
  • Like: the entity store flips the like at once, the API call follows, and a failure rolls back.
  • Hide or close: the analytics tracker flushes the current watch session with navigator.sendBeacon.

Think first

A teammate proposes storing currentTime for the active video in React state so a progress bar can render it. What would you suggest instead?

Data model and interface

The architecture named the stores; this chapter decides what lives in each. The client holds five kinds of data, and the mistake to avoid is putting them all in one place. Server data (videos, authors, the feed order) changes on the server and is cached, and viewer state (likes, follows) is server data that the user edits optimistically.

Playback state changes many times a second and belongs to the controllers. Preloaded media is large and short-lived. Settings and analytics queues must survive a reload.

DataWhere it livesWhy there
Feed order: list of video ids, cursor, dedupe setFeed store (a query cache entry with pages)Paginated server data, refetched only on demand.
Videos and authorsEntity store, normalized by idOne author appears on many items; a follow must update all of them.
Viewer state: liked, followed, pending mutationsEntity store, beside the entitiesEdited optimistically and rolled back on failure.
Active index, sound on, sheet openSmall UI storeRead by many components, written by a few.
Per-shell player state, current time, buffered rangesPlayback controller (plain objects, not React state)Changes several times a second.
First segments and throughput estimatePreloader, in memory, capped to the windowLarge and only useful for a few seconds.
Data saver, captions preferencelocalStorageMust survive reloads; tiny.
Unsent analytics eventsMemory, plus localStorage as a fallbackSo a crash or a failed beacon doesn't lose watch time.
ts
type Video = {
  id: string; authorId: string; description: string; durationMs: number;
  posterUrl: string; blurDataUrl: string;
  streams: { hls: string; dash: string; mp4Fallback: string };
  captions: { lang: string; url: string; auto: boolean }[];
  stats: { likes: number; comments: number; shares: number };
};
type Author = { id: string; handle: string; avatarUrl: string };
type Viewer = {
  liked: Record<string, boolean>;          // videoId -> liked
  followed: Record<string, boolean>;       // authorId -> followed
  pending: Record<string, "like" | "unlike" | "follow" | "unfollow">;
};
type Feed = { ids: string[]; seen: Set<string>; cursor: string | null; status: "idle" | "loading" | "error" };
type PlayerState = "idle" | "loading" | "ready" | "playing" | "paused" | "buffering" | "blocked" | "error";
type WatchSession = {
  videoId: string; startedAt: number; watchedMs: number;
  loops: number; maxPositionMs: number; sound: boolean; quality: number;
};
The client entities. The feed holds ids only; the details live in normalized tables.
Client data model in three homes. The normalized server cache holds Feed with ids, cursor, and the seen set; Video with streams and stats; Author with handle and avatar; and Viewer with liked and followed maps. Feed ids point to Video, Video authorId points to Author, Viewer liked points to Video and Viewer followed points to Author. Controllers, not React state, hold the five Players and the Segments for the window. Persisted state holds Settings in localStorage and the Watch queue with its beacon backup.normalized server cacheFeedids, cursor, seenVideostreams, statsAuthorhandle, avatarViewerliked, followedidsauthorIdlikedfollowedcontrollers, not React statePlayers5 shellsSegmentswindow onlypersistedSettingslocalStorageWatch queuebeacon, backup
The feed holds ids only, so a follow updates one Author record and every item by that author changes at once. Fast-changing playback state and big media buffers never enter the cache.

The endpoints

http
PUT    /api/videos/:id/like          -> 204   (like; repeating it changes nothing)
DELETE /api/videos/:id/like          -> 204   (unlike)
PUT    /api/authors/:id/follow       -> 204
DELETE /api/authors/:id/follow       -> 204
GET    /api/videos/:id/comments?cursor=...&limit=20
       -> { items: Comment[], nextCursor: string | null, total: number }
POST   /api/videos/:id/comments      { text, parentId? }  -> 201 Comment
POST   /collect/watch                [WatchEvent, ...]    -> 204  (via sendBeacon)

Errors: { "error": { "code": "rate_limited", "message": "...", "retryAfterMs": 2000 } }
429 and 5xx retry with backoff; 401 asks to sign in and keeps the optimistic state pending.
Besides GET /api/feed from chapter 8. All writes are idempotent so retries are safe.

Counts on the overlay (likes, comments) come with the feed item and are not live. A video with a million likes doesn't need a socket to show 1.2M; the count refreshes when the item is fetched again. The one exception is the viewer's own action: the count moves by one with their like, optimistically, so it always agrees with the red heart.

The shell's props and events

ts
type FeedShellProps = {
  index: number;              // position in the feed, sets the absolute top
  videoId: string | undefined;
  isActive: boolean;          // from the active tracker, after settling
  hasSource: boolean;         // distance to active is 1 or less
  soundOn: boolean;
  onTap(): void;              // single tap after 250 ms: toggle pause, or first-tap unmute
  onDoubleTap(x: number, y: number): void;   // like + heart at the finger
  onOpenComments(): void;
  onShare(): void;
  onFollow(authorId: string): void;
};
The feed shell: one per slot, reused as the window moves.

Check your understanding · Exercise 10

Which of these placements are right? Select all that apply.

Analytics: watch time, completions, and rewatches that actually arrive

For a recommendation feed, analytics is not a side feature. How long someone watched, whether they finished, whether it looped, and how fast they skipped are the main signals the ranking learns from. If the client over-counts (passing videos logged as views) or under-counts (watch time lost when the tab closes), the feed gets worse for that user. The analytics tracking lesson covers event design and batching in general; this chapter is about measuring video correctly and getting it off a phone that may close at any moment.

One watch session per active period

Instead of streaming an event every second, the tracker builds one summary per active period. It opens a WatchSession when a video becomes active (after settling, so passing videos never get one) and closes it when another video becomes active, the page hides, or the user leaves the feed. The summary is small: about 150 bytes. A 30 minute session with 150 videos sends about 22 KB of analytics, instead of thousands of tiny events.

  • Watch time adds up only time spent in the playing state: from the playing event to the next pause or waiting. Buffering and paused time don't count, so a slow network doesn't look like interest.
  • Loops: with the loop attribute, ended never fires. In timeupdate, if currentTime drops by more than half the duration since the last update, count a loop.
  • Completion: the furthest position reached is at least 95 percent of the duration, or at least one loop happened.
  • Quick skip: watch time under 2 seconds. The ranking treats this as a strong negative signal, so it must be accurate.
  • Quality of experience: time from becoming active to the first frame (requestVideoFrameCallback where supported, otherwise the playing event), the number and length of stalls, and the rendition. These drive the performance dashboards in chapter 15.
One watch session of a 14 second clip. At 0.0 s the video becomes active, watch time 0. At 0.2 s the first frame plays. At 4.0 s the buffer runs dry, waiting, with 3.8 s watched; the stall lasts 0.8 s and doesn't count. At 15.0 s it loops, with 14.0 s watched. At 22.0 s the page is hidden, with 21.0 s watched, and the session is sent with a beacon. Summary notes: watchedMs 21000, loops 1, completed; time to first frame 200 ms, 1 stall of 0.8 s.0.0 sactive0 s0.2 splaying0 s4.0 swaiting3.8 s15.0 sloop14.0 s22.0 shidden21.0 s22.0 sbeaconsentwatch time so farwatchedMs: 21000loops: 1, completedsound: offfirst frame: 200 msstalls: 1, 0.8 srendition: 720p
Watch time grows only in the playing state, so the stall doesn't inflate interest. The loop is detected by currentTime jumping back, since a looping video never fires ended.

Decision How should analytics leave the device?

One request per event

Every event calls fetch to the collector right away.

Pros

  • Lowest delay to the server. Simple.

Cons

  • Hundreds of requests per session, each waking the radio and competing with video segments.
  • Events in flight when the user closes the tab are lost.

Batch, and flush with sendBeacon when hidden

Closed sessions go into an in-memory queue. Flush with fetch every 10 sessions or 30 seconds.

On visibilitychange to hidden, close the current session and flush everything with navigator.sendBeacon(), which the browser delivers even after the page is gone.

Pros

  • Few requests, sent when the network is idle anyway.
  • Survives app switches, screen locks, and tab closes, which is when the last video's watch time would otherwise be lost.

Cons

  • Up to 30 seconds of delay for the ranking service.
  • Beacons are fire and forget: no response, and payloads are limited (Chrome caps beacon and keepalive bodies in flight at 64 KiB per page, and other browsers count slightly differently).

A WebSocket stream

Keep a socket open and send each event as it happens.

Pros

  • Low delay with little per-message overhead.

Cons

  • A long-lived connection to keep alive on mobile networks for data that can wait 30 seconds.
  • Still loses whatever is in flight when the page closes.

What we pick: Batch, and flush with sendBeacon on visibilitychange. The ranking service can wait 30 seconds; it can't recover data that never arrived. Hidden is the last moment the page can rely on, because on mobile a hidden page may be frozen or killed without any further events, which is also why unload and beforeunload must not be used for this.

ts
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "hidden") return;
  tracker.closeCurrentSession("hidden");
  const batch = queue.drain();
  if (batch.length === 0) return;
  const body = new Blob([JSON.stringify(batch)], { type: "application/json" });
  const queued = navigator.sendBeacon("/collect/watch", body);
  if (!queued) localStorage.setItem("pending-watch", JSON.stringify(batch)); // resend next visit
});
Flushing on hide. Keep a copy in localStorage in case the beacon is refused.

Two details keep the numbers honest. Each session carries a client-generated id, so if a batch is sent twice (a fetch that timed out but actually arrived, then a resend), the collector drops the duplicate. And when the page becomes visible again, the tracker opens a fresh session for the active video instead of resuming the closed one, so time spent away from the app is never counted as watching.

Check your understanding · Exercise 11

The ranking team says watch time for the last video of each session is almost always missing. Which fixes address it? Select all that apply.

Captions and accessibility

A feed of autoplaying, looping, full-screen videos is about as hard as accessibility gets. Deaf and hard of hearing users need captions. Screen reader users need to know what each video is and who made it, without a stream of noise every time it loops.

Keyboard users on desktop need to move between videos and control playback, and people with vestibular disorders need the motion turned down. Everyone who watches with the sound off, which is most people at first, benefits from captions too.

Captions

Each video ships WebVTT caption files: the creator's own when they provided them, otherwise auto-generated by speech recognition and labelled as such ("English, auto-generated"), because their accuracy is lower. Attach them with a track element of kind="captions"; if the files are on another origin, the video needs the crossorigin attribute or the browser refuses to load them.

Then set the track's mode to "hidden" and draw the cues yourself from the cuechange event. Native caption rendering puts text at the bottom of the video, exactly where the description and the action buttons sit. Custom rendering lets you place the caption above them, with a solid backdrop for contrast.

When should captions show? The default that serves most people: on whenever the video is muted, since a muted viewer can't hear the speech, and off once the user turns sound on, unless they turned captions on themselves. An explicit choice always wins and is stored with the other settings. Pages can't read the phone's caption settings directly, so a clear CC button on every video matters.

Screen readers and focus

  • Each shell is an article with an accessible name built from the author and description, like "Video by @lena_cooks: 3 ingredient pasta, 24 seconds". The buttons inside have their own labels: "Like, 12.3 thousand likes" with aria-pressed, "Comments, 418", "Share", "Follow @lena_cooks".
  • When the active video changes through the keyboard, move focus to the new article (it has tabindex="-1") with focus({ preventScroll: true }). The screen reader announces its name once. Don't also use a live region, or the user hears everything twice.
  • Never announce loops, progress, or buffering. A looping video that announces itself every 15 seconds makes the page unusable with a screen reader.
  • Because the feed is a native scroller, VoiceOver and TalkBack page gestures and swipe navigation between elements keep working. This is one of the reasons chapter 3 chose CSS scroll snap over a JavaScript swipe.
  • Text over video needs a gradient or solid backdrop behind it to meet contrast at every frame of the video. The visual accessibility lesson covers contrast in depth.

Keyboard

KeyActionNote
Arrow Down, Arrow UpNext or previous videoScrolls to the item and moves focus to it.
Space or KPlay or pauseOnly when focus is on the article itself. On a focused button, Space must press the button.
MMute or unmuteA key press is a user gesture, so it may turn sound on.
CCaptions on or offStored as an explicit choice.
LLike or unlikeThe same optimistic path as the button.
EscapeClose the comments sheetFocus returns to the comment button.

Single letter shortcuts can fire by accident for speech input users, which WCAG addresses in its character key shortcuts rule. So offer a way to turn them off in settings, and never handle them while focus is in a text field such as the comment composer. Also make every control reachable by Tab in a sensible order: the article, then follow, like, comment, share, then the next article's controls only once it's active.

Reduced motion and autoplay

When prefers-reduced-motion: reduce matches, the decorative motion goes first. Snap animations become instant jumps (scrollTo with behavior: "auto"), the heart burst becomes a simple fade, and progress bars stop animating.

Autoplay is the harder call. Video that starts moving by itself is exactly what this preference is about, and the WCAG rule Pause, Stop, Hide asks for a way to pause moving content that starts automatically and lasts more than 5 seconds. But a feed that never plays anything isn't the product.

A fair middle: with reduced motion, the first video waits with a large play button. Once the user presses play, swiping to the next video counts as asking for it, so it plays. A separate Autoplay setting lets anyone turn it off entirely, and the play button and the pause key are always there.

Check your understanding · Exercise 12

A screen reader user says the feed is unusable: it talks constantly, and they can't tell which video they're on. Which changes help? Select all that apply.

Performance budgets on a low-end phone

The feed must feel smooth on a phone that costs a fifth of a flagship. Such a phone has slower CPU cores, a slower GPU, and less memory, and it is also decoding video the whole time. Performance work starts with a budget, written down before the code, and checked in the field. The core web vitals and runtime performance lessons explain the metrics and tools; this chapter applies them to a video feed.

BudgetTarget on a mid to low-end Android, 4GHow it is met
JavaScript for the feed routeAbout 150 KB gzipped, before the streaming library loadsComments sheet, share fallback, and settings are lazy chunks.
Largest Contentful Paint (the first poster)Under 2.5 s at the 75th percentilePoster in server HTML, preloaded with high priority, same CDN as the page.
Time to first frame, first videoUnder 1.5 sInlined first page, first segment fetched as soon as the script runs, low first rendition.
Time to first frame after a swipeUnder 200 ms when preloadedFirst segment ready, element already attached, playback starts on settle.
Interaction to Next Paint (taps, likes)Under 200 msOptimistic updates, tiny handlers, no layout reads in handlers.
Cumulative Layout ShiftClose to 0Every item and overlay part has a fixed size from the first paint.
Main thread work per frame while swipingUnder about 10 ms, no long tasksNative scrolling, and all heavy work after settling.

What runs during a swipe, and what waits

During the gesture itself, the browser's compositor scrolls the feed and the main thread should be almost idle: a few IntersectionObserver callbacks that update a Map. Everything expensive waits for the settle: the React update of the shells' props, attaching the stream, starting preloads, and closing the analytics session.

Even then, split it. Playing the active video comes first; preload planning, the next page fetch, and analytics come after, in a later task, so a second swipe that starts right away isn't blocked. Use scheduler.yield() or scheduler.postTask() where the browser has them, and a setTimeout(0) fallback elsewhere.

Main thread work against a 16.7 millisecond frame budget. While swiping, only about 2 ms of IntersectionObserver work runs and the compositor scrolls. At a naive settle, render 6 ms, attach 4, preload 4, stats 3, and layout 3 add up to 20 ms, past the budget, so a frame drops. Split instead: the first task renders and plays in 9 ms; the next task does preload, page, and stats in 10 ms.16.7 msswipingIOsettle, naiverenderattachpreloadstatslayoutsettle, task 1renderplaynext taskpreloadpagestatsnaive settle: 20 ms, one dropped frame
The same work fits once it is split. Playback goes first because it is what the user waits for; everything else moves to a later task.

Some work can leave the main thread entirely. hls.js parses and repackages segments in a Web Worker by default. Parsing the next feed page is small enough to stay. Building analytics batches is cheap, but JSON.stringify of a large queue on hide is not, which is another reason to flush in batches of 10 rather than letting the queue grow.

Overlays without layout thrash

  • Contain each item: contain: layout paint on the shell means a change inside one item can't force layout of the whole feed.
  • Fixed sizes for changing text: the like count changes from 999 to 1K on a tap. Give the count a fixed width and font-variant-numeric: tabular-nums, so the button column never shifts.
  • No measuring in handlers: the heart position comes from the event's coordinates, never from getBoundingClientRect() after a style write. A read after a write forces a synchronous layout, which on a weak phone can take 10 ms or more.
  • Clamp with CSS: the description shows two lines with line-clamp and a More button. Don't measure text height in JavaScript to decide where to cut it.
  • Compositor-only animation: the heart, the sheet slide, and the progress bar animate transform and opacity. Use will-change only during an animation; every promoted layer costs GPU memory that video decoding also needs.

Decoding cost and battery

Video decoding in hardware is cheap; in software it can eat a whole CPU core and drain the battery. Before choosing a codec, ask navigator.mediaCapabilities.decodingInfo() for each candidate (for example AV1, HEVC, H.264 at the chosen size), and prefer one that reports powerEfficient: true. On many cheaper phones that means H.264, even though AV1 would save bytes. Ask once per session and cache the answer.

Measuring in the field

Lab tests on a fast laptop say almost nothing about this product. Test on a real low-end Android phone, and collect real user metrics from everyone: LCP and INP from the standard web vitals, plus custom metrics only a video feed has.

Those are time to first frame after a swipe, stalls per minute of watch time, the share of swipes where the next video wasn't ready, and dropped frames during swipes (the Long Animation Frames API in Chromium shows what caused them). Break each down by device class and connection, because the averages hide the users who suffer.

Check your understanding · Exercise 13

Swipes stutter on a cheap Android phone but are smooth on a flagship. Which changes are likely to help? Select all that apply.

Running it in the interview

Forty-five minutes is not enough to say everything in this guide, so choose. Interviewers for this question usually care most about three things: how you decide which video plays, how you make the next one start instantly without wasting data, and how you keep a phone from running out of memory. Spend most of your time there, and touch the rest with one or two sentences each so the interviewer knows you know it exists.

MinutesWhat to doWhat to draw or say
0 to 5RequirementsDevices, networks, swipe speed, sound, personal feed. Say the two driving numbers: skips under 2 s, only a few videos alive.
5 to 10The screen and the five hard partsDraw the wireframe: one video, the overlay column, the caption. List the five properties from chapter 2.
10 to 18Scrolling and the active videoScroll snap with scroll-snap-stop: always. The candidate versus active split with a settle rule. Draw the flick timeline.
18 to 26Playback and preloadingMuted start and the first-tap unmute. The preload ladder: stream, first segment, manifest. Abort on change.
26 to 32MemoryThe window of 5 recycled shells, 3 with sources, and how a source is released.
32 to 38Architecture and APIThe component diagram with the active index as the hinge. The cursor feed, dedupe, fetch when 3 remain. Optimistic likes.
38 to 45Deep dive the interviewer picksAccessibility, analytics, data saver, or performance on low-end phones.

Draw the wireframe first and the flick timeline second. The timeline is the single most convincing picture in this interview: it shows in one glance why "play what's visible" is wrong and why a settle rule is needed. Then draw the preload ladder on top of the same row of items, and the window of mounted shells under it. With those three drawings, most of the design is on the board.

Follow-up questions to expect

Think first

How would you insert full-screen ads into the feed?

Think first

What changes on desktop?

Think first

The product wants the next 10 videos available on a subway with no signal. How would you do it?

Check your understanding · Exercise 14

With 10 minutes left, the interviewer asks what you'd watch first after launch. Which answers show the strongest judgement? Select all that apply.

Summary

  • Two numbers drive the design: people skip in under 2 seconds, and a phone can only keep a few videos alive at once.
  • Use CSS scroll snap with scroll-snap-stop: always on a fixed inner scroller sized in dvh, with safe area padding for the controls. The browser keeps swipes smooth and accessible.
  • Separate the candidate (most visible now) from the active video (settled). Commit on scrollend or after 150 ms, so videos a flick passes through never play or download.
  • Start muted with playsinline, always handle the promise from play(), unmute on the first tap, and pause on hidden tabs.
  • Preload as a ladder: the active video streams, the next gets its first segment, the one after its manifest. Abort everything that leaves the window, and serve a poster for instant first paint.
  • Recycle a fixed window of 5 shells with at most 3 sources, and release sources properly with removeAttribute("src") and load().
  • Paginate with an opaque cursor, deduplicate by id, and fetch the next page when 3 items remain.
  • Measure watch time only while playing, summarize per active period, and flush with sendBeacon on visibilitychange.

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 3
Mid-level 0 of 4

References

Concepts behind it

Try next