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.
| Question | Answer we assume | What 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. |
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.
- 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.
- 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.
- 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.
- 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.
- 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.
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?
A virtualized list solves the number of DOM nodes, but not the media. Lazy loading tells you when something is near the screen, not when the user has stopped on it, so every passing video would start.
A list card has no idea of being the current item. The feed needs exactly one playing item, a policy for sound, a preload window shaped around that current item, and recycling of the expensive video elements.
Free scrolling also lets the user stop halfway between two videos. The product wants each swipe to land on exactly one video, which needs snapping.
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?
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.
The CSS, line by line
.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));
}
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.
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?
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.
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
}
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.
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
| Situation | Chrome and Edge | Safari (iOS and macOS) | Firefox |
|---|---|---|---|
| Muted autoplay | Allowed. | 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 visit | Blocked. | Blocked. | Blocked by default (a user setting). |
| Sound after the user tapped or clicked the page | Allowed: 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 visit | Sometimes 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 offscreen | Muted 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?
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.
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.
- Becoming active moves
readytoplayingunlessuserPausedis set. Becoming inactive pauses and resetscurrentTimeto 0, so coming back starts the clip from the start, a common choice for short looping clips. - Tab hidden (
document.visibilitychangewithdocument.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
blurannoys people watching while typing elsewhere. - Looping: set the
loopattribute. Note that a looping video never firesended, so count loops by watchingcurrentTimejump back near 0 intimeupdate(chapter 13). - Blocked shows a play button. A tap on it is a gesture, so it can start the video, with sound if
soundOnis 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.
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?
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.
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.
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.
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
| Cost | Rough size | When it is paid |
|---|---|---|
| The DOM element and its listeners | Kilobytes | As 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 data | Once it has a source and starts loading. |
| Decoded frames: one 720 by 1280 frame is about 1.4 MB, and a decoder keeps several | Roughly 10 to 30 MB per decoding video | While it decodes: playing, or preparing the first frame. |
| A hardware decoder slot | Phones have only a few; when they run out the browser falls back to slow software decoding or fails | While 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?
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.
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.
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>
);
}
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?
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.
// 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 };
};
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.
- Expired cursor: the server replies
410 Gonewith 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/IDdoes the same with that video first. - Going back to the feed: keep the items and active index in memory (and in
sessionStoragefor 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)?
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.
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;
Escapeand 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
dvhand 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?
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.
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
}
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.
| Piece | Responsibility | Why it is separate |
|---|---|---|
| Feed page and scroller | Renders the 5 shells, the snap container, and the overlays. Reads the store. | The only React-heavy part. Knows nothing about bytes or decoders. |
| Active tracker | Runs the IntersectionObserver and the settle rule, and writes activeIndex. | One clear owner of the most important value. Easy to test with fake entries. |
| Playback controller | One state machine per shell. Applies autoplay, sound, visibility, and looping rules. | Media events stay here instead of in React state. |
| Preloader | Turns 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 adapter | Feeds 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 store | Items in order, the cursor, the dedupe set, loading and error state. | Server data, cached and paginated: a query cache handles it well. |
| Entity store | Normalized 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 sheet | Lazy 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 tracker | Listens to playback events, builds watch sessions, batches and sends them. | Must keep working while the page hides or closes. |
| Settings | Data 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.
- 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?
Every timeupdate would re-render the progress bar and anything else that reads that state, several times a second, and more with smooth bars that use requestAnimationFrame.
Let the progress bar subscribe directly to the playback controller and update a CSS custom property or a transform: scaleX() on one element. That is one style write per frame, with no React render.
Keep React state for discrete facts that change rarely, such as playing or paused.
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.
| Data | Where it lives | Why there |
|---|---|---|
| Feed order: list of video ids, cursor, dedupe set | Feed store (a query cache entry with pages) | Paginated server data, refetched only on demand. |
| Videos and authors | Entity store, normalized by id | One author appears on many items; a follow must update all of them. |
| Viewer state: liked, followed, pending mutations | Entity store, beside the entities | Edited optimistically and rolled back on failure. |
| Active index, sound on, sheet open | Small UI store | Read by many components, written by a few. |
| Per-shell player state, current time, buffered ranges | Playback controller (plain objects, not React state) | Changes several times a second. |
| First segments and throughput estimate | Preloader, in memory, capped to the window | Large and only useful for a few seconds. |
| Data saver, captions preference | localStorage | Must survive reloads; tiny. |
| Unsent analytics events | Memory, plus localStorage as a fallback | So a crash or a failed beacon doesn't lose watch time. |
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 endpoints
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.
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
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;
};
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
playingstate: from theplayingevent to the nextpauseorwaiting. Buffering and paused time don't count, so a slow network doesn't look like interest. - Loops: with the
loopattribute,endednever fires. Intimeupdate, ifcurrentTimedrops 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 (
requestVideoFrameCallbackwhere supported, otherwise theplayingevent), the number and length of stalls, and the rendition. These drive the performance dashboards in chapter 15.
Decision How should analytics leave the device?
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.
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
});
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
articlewith 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" witharia-pressed, "Comments, 418", "Share", "Follow @lena_cooks". - When the active video changes through the keyboard, move focus to the new
article(it hastabindex="-1") withfocus({ 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
| Key | Action | Note |
|---|---|---|
| Arrow Down, Arrow Up | Next or previous video | Scrolls to the item and moves focus to it. |
| Space or K | Play or pause | Only when focus is on the article itself. On a focused button, Space must press the button. |
| M | Mute or unmute | A key press is a user gesture, so it may turn sound on. |
| C | Captions on or off | Stored as an explicit choice. |
| L | Like or unlike | The same optimistic path as the button. |
| Escape | Close the comments sheet | Focus 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.
| Budget | Target on a mid to low-end Android, 4G | How it is met |
|---|---|---|
| JavaScript for the feed route | About 150 KB gzipped, before the streaming library loads | Comments sheet, share fallback, and settings are lazy chunks. |
| Largest Contentful Paint (the first poster) | Under 2.5 s at the 75th percentile | Poster in server HTML, preloaded with high priority, same CDN as the page. |
| Time to first frame, first video | Under 1.5 s | Inlined first page, first segment fetched as soon as the script runs, low first rendition. |
| Time to first frame after a swipe | Under 200 ms when preloaded | First segment ready, element already attached, playback starts on settle. |
| Interaction to Next Paint (taps, likes) | Under 200 ms | Optimistic updates, tiny handlers, no layout reads in handlers. |
| Cumulative Layout Shift | Close to 0 | Every item and overlay part has a fixed size from the first paint. |
| Main thread work per frame while swiping | Under about 10 ms, no long tasks | Native 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.
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 painton 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-clampand 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
transformandopacity. Usewill-changeonly 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Requirements | Devices, networks, swipe speed, sound, personal feed. Say the two driving numbers: skips under 2 s, only a few videos alive. |
| 5 to 10 | The screen and the five hard parts | Draw the wireframe: one video, the overlay column, the caption. List the five properties from chapter 2. |
| 10 to 18 | Scrolling and the active video | Scroll snap with scroll-snap-stop: always. The candidate versus active split with a settle rule. Draw the flick timeline. |
| 18 to 26 | Playback and preloading | Muted start and the first-tap unmute. The preload ladder: stream, first segment, manifest. Abort on change. |
| 26 to 32 | Memory | The window of 5 recycled shells, 3 with sources, and how a source is released. |
| 32 to 38 | Architecture and API | The component diagram with the active index as the hinge. The cursor feed, dedupe, fetch when 3 remain. Optimistic likes. |
| 38 to 45 | Deep dive the interviewer picks | Accessibility, 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?
As feed items of another type, returned by the feed API at positions the server chooses, so the client renders them with the same shell and snapping rules.
Ad videos often come from another CDN with their own tracking. Preload them like any other item, but keep their tracking scripts out of the main bundle and sandboxed.
Ads have their own measurement rules (for example how long an impression must be visible), so the analytics tracker emits a separate ad event alongside the watch session.
Think first
What changes on desktop?
The video is a 9:16 column in the middle of a wide screen, with comments in a side panel rather than a bottom sheet, so the video stays fully visible.
Wheel and trackpad input matter more: scroll-snap-stop: always stops a long trackpad swipe from skipping videos, and arrow keys move one video at a time.
Desktop has more memory and decoders, but keep the same window. The design that is safe on phones costs nothing on a laptop.
Think first
The product wants the next 10 videos available on a subway with no signal. How would you do it?
That is a download feature, not preloading. A service worker would fetch the 10 videos in full at one low rendition while on Wi-Fi, store them in the Cache API, and serve them when offline.
It costs storage (10 clips at 480p is about 15 to 25 MB), so ask the user, show the size, and evict after a day.
Analytics from offline viewing go into the persistent queue and are sent when the connection returns.
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: alwayson a fixed inner scroller sized indvh, 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
scrollendor after 150 ms, so videos a flick passes through never play or download. - Start muted with
playsinline, always handle the promise fromplay(), 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")andload(). - 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
sendBeacononvisibilitychange.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- Autoplay policy in Chrome When Chrome allows sound, the Media Engagement Index, and handling the play() promise.
- New video policies for iOS (WebKit) Where muted autoplay and the playsinline attribute on iPhone came from.
- Autoplay guide for media and Web Audio APIs (MDN) Cross-browser autoplay rules and how to detect a blocked start.
- Fast playback with audio and video preload (web.dev) The preload attribute, link preload, and preloading the first segment with Media Source Extensions.
- scroll-snap-stop (MDN) How always stops a fast flick at the next snap point.
- The large, small, and dynamic viewport units (web.dev) svh, lvh, and dvh, and how mobile toolbars affect them.
- Intersection Observer API (MDN) Roots, thresholds, and intersection ratios.
- ManagedMediaSource (MDN) The Media Source variant that brought script-driven streaming to iPhone.
- Navigator: sendBeacon() (MDN) Why to send analytics on visibilitychange rather than unload.
- HTML Standard: media elements Media element states, events, and the best practice for releasing a media resource.
Concepts behind it
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Pagination: offset, cursor, and keysetWays to load a long list one piece at a time, and why the choice decides whether users see duplicates, miss items, or wait on slow deep pages when the list changes.
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Runtime performanceKeeping the page responsive after it loads: finding long tasks on the main thread, splitting or moving that work, and avoiding needless re-renders so every click and keypress paints in under 200 ms.
- Memory in long-lived appsWhy single-page apps that stay open for hours fill up memory, how the garbage collector decides what to keep, the classic leaks, and how to find them with heap snapshots and keep them out with budgets.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Product analyticsRecording what users do as well-named events that follow a tracking plan, delivered reliably even offline, with consent respected, and checked so that the numbers people decide with are true.