cf.completefrontend
My progress
SYSTEM DESIGN · #89

Design Netflix

A video streaming client with a browse page of many personalized rows of artwork, delayed trailer previews on hover and focus, a TV interface driven by a remote, and a protected player that adapts quality to the network and remembers where you stopped. The hard parts are a browse page that stays smooth with thousands of images, focus that moves sensibly with four arrow keys, and playback that starts fast and never stalls.

The question

Design the client for Netflix. A member signs in, picks a profile, and lands on a browse page with dozens of rows of title artwork. Hovering or focusing a title plays a short muted preview. Picking a title opens its details, and pressing play starts the video at the right quality for the network, from where they stopped last time, on any device.

The same product runs on desktop browsers and on smart TVs, where the only input is a remote with arrow keys, OK, and Back. Walk me through the browse page, the TV navigation, and the player, and be ready to talk about profiles, kids mode, and how you'd know playback quality is good.

Real-life examples: Netflix on web and smart TVs, Disney+, Max, Prime Video, Apple TV+

Requirements: what to ask before drawing anything

"Design Netflix" covers a sign-up funnel, a profile picker, a browse page, search, a title detail view, a video player, downloads, and a TV app. That is far too much for 45 minutes. Spend the first five minutes agreeing which surfaces get depth. Ask the questions whose answers change the design, and say out loud what each answer changes.

A useful order: surfaces (which screens), devices and inputs (mouse, touch, remote), content and scale (how many rows, how many titles, how long the videos are), and rules (content protection, kids, search engines). The table shows the answers this guide assumes.

QuestionAnswer we assumeWhat it changes
Which screens?Profile picker, browse page, title details, and the player. Search and sign-up are follow-ups.Which chapters get depth.
Which devices?Desktop browsers and smart TVs. Phones use native apps, so mobile web is a simple fallback.TV means a remote, a weak CPU, and a 10-foot layout (sized to be read from a sofa about 3 metres away). It shapes the browse page more than the desktop does.
How big is the browse page?About 40 rows, each with up to 75 titles. So up to 3,000 pieces of artwork on one page.Virtualization in two directions, lazy loading of rows, and image sizes.
Is the content protected?Yes. Studios require DRM (digital rights management) for every licensed title.The player must use Encrypted Media Extensions (EME, the browser API for protected playback) and a license server, which changes startup and which browsers get which quality.
Are previews in scope?Yes. Hovering on desktop or focusing on TV plays a muted trailer after a short pause.A preview state machine, cancellation, and a lot of wasted bandwidth to avoid.
Do we resume across devices?Yes. Stop on the TV, continue on the laptop at the same second.Progress heartbeats, a Continue Watching row, and conflict rules.
Profiles and kids?Up to 5 profiles per account, each with its own rows, history, and maturity limit. Kids profiles show only kids titles.Every request is scoped to a profile, and caches must never mix two profiles.
Search engines?Only the logged-out landing page and public title pages. The browse page is private.Server rendering only where it pays off.
Wireframe of the Netflix browse page in a browser window. At the top, a navigation bar with the logo, Home, Shows, Movies, My List, search, and the profile. Below it, a large billboard with hero art, a trailer, and Play and More info buttons; it is the LCP element. Then the Continue Watching row with five cards showing progress, and a next-page arrow. Then the Trending Now row, where the second card is hovered and grows into a preview card, also with a next-page arrow.netflix.com/browselogo · Home · Shows · Movies · My Listsearch · profilebillboard: hero art and trailer (LCP)PlayMore infoContinue Watching60%20%85%40%10%›Trending Nowhover›
Every part here is a separate design problem: the billboard is the LCP element, each row is its own horizontal list with a page arrow, and the hovered card is where previews start.

Functional requirements

  • Pick a profile after sign-in, with an optional 4-digit lock on a profile.
  • Show a browse page: a large hero ("billboard") at the top, then rows such as Continue Watching, Trending Now, and Because You Watched X.
  • Scroll the page down through rows, and scroll each row sideways through its titles, with mouse, touch, keyboard, or remote.
  • On hover (desktop) or focus (TV), enlarge the card and play a muted preview after a short delay. Members can turn previews off.
  • Open a title's details: synopsis, cast, rating, seasons and episodes, and similar titles.
  • Play a title with adaptive quality, subtitles, audio languages, skip intro, and a countdown to the next episode.
  • Resume from the last position on any device, and keep Continue Watching up to date.

Non-functional requirements

  • Startup time: press play to first frame under 2 seconds on a good connection. Viewers notice waits of a few seconds, and some leave.
  • Smooth playback: rebuffering (the video pausing to load) under 0.5 percent of watch time.
  • Smooth browsing: scrolling and focus moves at 60 frames per second on desktop and at least 30 on a slow TV, with each focus move answered within 100 ms.
  • Memory: TV apps may get only a few hundred megabytes. Thousands of decoded images would not fit.
  • Accessibility: full keyboard and screen reader support on the web, captions on every title, and previews that respect reduced motion.
  • Isolation: one profile never sees another profile's rows, history, or cached data.

Out of scope, and why

  • Offline downloads: Netflix offers downloads only in its mobile apps, not on the web. Mention that the native apps store encrypted files with an offline license, then move on.
  • The recommendation model: the backend ranks rows and titles. The client only shows what it gets and reports what the member did.
  • Encoding and the CDN (content delivery network, the servers that hold the video files near members): the server side prepares every title at many qualities. We care about how the client picks among them.
  • Sign-up and payments: a normal form flow with no special frontend problem.

Check your understanding · Exercise 1

Which answers from the interviewer would change the client design the most? Select all that apply.

What makes Netflix hard

It's tempting to call Netflix "YouTube with a subscription". The YouTube guide already covers a watch page, a basic adaptive player, and a mini-player. Netflix differs in five ways, and each one drives a later chapter.

  1. The browse page is a grid of grids. YouTube's home is one long list of cards. Netflix's is a vertical list of rows, and each row is its own horizontal list. Both directions need windowing, and the two interact: when you scroll down, a whole row of 75 cards enters the screen at once.
  2. The browse page is mostly images. A desktop screen shows about 6 rows of 6 cards, so 36 images plus a full-width hero, all visible together. Every pixel of that artwork was picked for this member. Image bytes, decode time, and memory are the main costs, not JavaScript.
  3. Previews play video while you browse. Hovering a card for a moment starts a muted trailer. Done carelessly, a member sweeping the mouse across a row starts 10 video downloads in 2 seconds. Previews are the most wasteful feature on the page unless they are cancelled well.
  4. The main device is a TV with a remote. Netflix has said for years that most viewing happens on TVs. A remote has four arrows, OK, and Back. There is no pointer and no scroll wheel, so the app itself must decide where focus goes for every key press, and it must do it on a CPU much slower than a laptop.
  5. Every title is protected. Licensed films and shows require DRM. The player cannot just fetch a file: it must fetch encrypted segments, ask a license server for keys, and hand both to the browser's protected decoder. Which DRM system a browser supports decides the best quality it may play.

Two more things raise the bar. Members are paying, so a stall or a slow start feels like a broken product, and they can cancel any month. And a household shares one account across several profiles and devices, so state like "where did I stop?" must follow the profile, not the device.

TraitWhat it forcesChapter
Rows of rowsTwo-direction virtualization, lazy row loadingThe browse page
Artwork everywhereRight-sized, modern image formats, decode budgets, personalized artArtwork
PreviewsA delay, cancellation, a preview state machine, prefetching manifestsPreviews
TV and remoteSpatial navigation, focus memory per row, a 10-foot layoutTV and the remote
DRMEME, license requests, per-browser quality limitsThe player, DRM
Shared accountsProfile-scoped caches, cross-device resumeContinue Watching, profiles

Think first

If you had to drop one of these five traits to make the problem easier, which one would remove the most frontend work, and why?

The browse page: rows of rows

Start with the naive version, because it's what most people build first. Fetch the whole page as JSON, then render 40 row components, each mapping over its 75 titles to an img inside a card. Add loading="lazy" to the images and call it done.

Count what that costs. A card with its image, title overlay, progress bar, and hover controls is about 15 DOM nodes. 3,000 cards is 45,000 nodes before the hover card or the hero. Style and layout work grows with the node count, so every hover that changes a class can trigger seconds of style recalculation on a TV.

The JSON for 3,000 titles with artwork URLs, ratings, and tags is around 1 to 2 MB, and the server must rank every row before it can send the first byte. Native lazy loading helps with image downloads, but the nodes, the layout, and the payload are all still there.

Decision How should the browse page keep the number of rendered cards small?

Render everything, rely on lazy images

Render all rows and all cards.

Use loading="lazy" and decoding="async" on images.

Pros

  • Simplest code.
  • Find in page and screen reader browsing see every title.

Cons

  • 45,000 nodes and a large first payload.
  • Slow on TVs, where memory and CPU are tight.
  • Nothing stops off-screen cards from holding decoded images.

content-visibility: auto on each row

Render everything, but give each row content-visibility: auto and a contain-intrinsic-size.

The browser skips layout and paint for rows off screen.

Pros

  • One line of CSS, keeps the DOM complete.
  • Supported in current Chrome, Edge, Firefox, and Safari.

Cons

  • The nodes and their memory still exist.
  • Doesn't help the payload or the server's ranking time.
  • TV browsers and custom TV renderers may not support it.

Vertical windowing only

Render only rows near the viewport, with a few rows of overscan (extra rows rendered just outside the screen).

Each rendered row renders all 75 of its cards.

Pros

  • Cuts rows from 40 to about 8.
  • Rows have a fixed height, so the math is easy.

Cons

  • 8 rows of 75 cards is still 600 cards, 9,000 nodes.
  • Most of those cards are off to the right where no one sees them.

Two-direction windowing with lazy rows

Window rows vertically.

Inside each row, render only the visible page of cards plus one page each side.

Fetch rows in chunks as the member scrolls, and fetch a row's later titles only when they page toward them.

Pros

  • About 8 rows of 18 cards: around 150 cards and 2,000 nodes, whatever the page size.
  • Small first payload and a fast first response.
  • Works the same on a TV renderer.

Cons

  • More code: two windows, saved scroll state, placeholder rows.
  • Off-screen titles are not in the DOM, so screen readers need help (see the accessibility chapter).

What we pick: Two-direction windowing with lazy rows. The browse page is a fixed grid of known sizes, which is the easiest case for windowing: every row has the same height and every card the same width at a given screen size. Add content-visibility on top for desktop rows that are rendered but just off screen. The virtualization lesson covers the general technique; here we look at what is special about rows of rows.

Two-direction windowing over six rows, each with eight pages of six cards. Rows 2, 3, and 4 are on screen, rows 1 and 5 are overscan, and row 6 is not mounted. Row 2 shows page 1 and renders pages 1 and 2. Row 3 shows page 3 and renders pages 2, 3, and 4. Row 4 shows page 2 and renders pages 1, 2, and 3. Overscan rows render their current page and the next. In total 12 pages, 72 of the 288 cards in these rows, exist in the DOM.pages of 6 cards, left to rightoverscan66screen66screen666screen666overscan66unmountedoutlined: the page on screen, green: rendered neighboursblank: not in the DOM
Each mounted row renders only its visible page and its neighbours, and rows far from the screen aren't mounted at all. However long the page gets, the DOM stays at about the same size.

Fixed geometry makes the math cheap

Netflix rows don't scroll freely like a phone carousel. On desktop they move by a page: an arrow at the edge shifts the row by exactly the number of visible cards.

That choice is not only visual. It means the row's state is one integer, the page index, and the visible cards are always page * perPage to page * perPage + perPage - 1. The number of cards per page comes from the viewport width, so card width is computed, not measured.

ts
function cardsPerPage(viewportWidth: number): number {
  if (viewportWidth >= 1400) return 6;
  if (viewportWidth >= 1100) return 5;
  if (viewportWidth >= 800) return 4;
  if (viewportWidth >= 500) return 3;
  return 2;
}

function renderedRange(page: number, perPage: number, total: number) {
  const first = Math.max(0, (page - 1) * perPage);
  const last = Math.min(total - 1, (page + 2) * perPage - 1);
  return { first, last }; // up to 3 pages, e.g. 18 cards at 6 per page
}
The cards a row renders: the visible page plus one page each side, so a page move never shows a blank slot.

The row's track is one element moved with transform: translateX(...), so a page change is a compositor animation, not a layout. Cards inside the track are absolutely positioned at index * cardWidth, so cards entering or leaving the rendered range never shift their neighbours.

Remember each row's position after it unmounts

When a row scrolls out of the vertical window, its component unmounts. If the member had paged that row to page 3 and scrolls back up, it must still be on page 3. So the page index can't live in the row's own state.

Keep a small map in the browse page's store: row id to page index (and, on TV, row id to the focused card index). It's a few bytes per row and it survives unmounting. The same map is saved to history.state so the Back button from a title's details restores both the vertical scroll and every row's page.

Loading rows lazily

The first response carries the hero and the first 6 to 10 rows, each with only its first two pages of titles (about 12 per row). That's enough to fill the screen and the first page move.

A sentinel (an empty marker element) a few rows below the last loaded row is watched with an IntersectionObserver using a rootMargin of about one screen height. When it comes into range, the client asks for the next chunk of rows. Inside a row, when the member reaches the second-to-last loaded page, the client fetches the next page of that row's titles.

Placeholders matter. A row that is known but not yet loaded renders at its final height with grey card shapes, so the page never jumps.

If a row fails to load, retry once with backoff, and if it still fails, remove the row quietly rather than show an error in the middle of the page. A browse page with 39 rows is fine. A browse page with an error box is not.

Sequence diagram of lazy row loading between the member, the browse page, the API client, and the BFF. The member scrolls down. The browse page sees the sentinel and asks for the next rows. The API client sends GET /browse with cursor c8. The BFF replies with 6 rows and their titles, the cache fills, and the rows render. Later the browse page asks for more titles in row r9. The API client sends GET /rows/r9/titles. The BFF fails with a 500 twice, after one retry, so the browse page removes row r9 quietly.MemberBrowse pageAPI clientBFFscrolls downsentinel: next rowsGET /browse (c8)6 rows + titlescached: rows renderrow r9: more titlesGET /rows/r9/titles500, retry, 500drop row r9 quietly
The happy path costs one request per chunk of rows, sent before the member reaches them. The failure path ends with a smaller page, never an error box in the middle of the rows.

Check your understanding · Exercise 2

A member pages the "Trending Now" row to page 3, scrolls down 20 rows, then scrolls back up. With two-direction windowing, which statements are true? Select all that apply.

Artwork: sizes, formats, and personal choices

The browse page is mostly pictures, so image decisions decide how it feels. The naive approach is one artwork file per title, say a 1280 by 720 JPEG of about 150 KB, used everywhere: in cards, in the hover card, and in the details view.

Work out the cost on a desktop. About 36 cards are visible, so the first screen downloads 36 times 150 KB, around 5.4 MB, before the hero. On a 10 Mbps connection that's over 4 seconds of images.

Memory is worse. A decoded image takes width times height times 4 bytes, so 1280 by 720 is about 3.7 MB in memory. With 150 cards rendered, that's over 500 MB of decoded pixels. A TV app with a few hundred megabytes in total would crash or start dropping frames.

Now the right size. At 6 cards per row on a 1920-pixel-wide screen, each card is about 300 CSS pixels wide, so a 341 by 192 image is enough at a device pixel ratio of 1. In WebP or AVIF that's roughly 10 to 20 KB, and decoded it's about 260 KB.

The first screen drops to well under 1 MB, and 150 rendered cards hold about 40 MB of pixels. Same picture, more than ten times cheaper.

Before and after for card artwork. Before: one 1280 by 720 JPEG for every card, about 150 KB to download and 3.7 MB decoded, so 150 rendered cards hold about 550 MB of pixels. After: a 341 by 192 AVIF sized to the card, about 15 KB to download and 0.26 MB decoded, so 150 cards hold about 39 MB.before: one size for allafter: sized to the card1280 × 720 JPEG341 × 192 AVIF150 KB to download3.7 MB decoded150 cards: about 550 MBabout 15 KB to download0.26 MB decoded150 cards: about 39 MB
The file size sets download time, but the pixel size sets memory. Sizing art to the card cuts both by more than ten times, which is what lets 150 mounted cards fit in a memory budget.

Decision Who decides which file each card loads?

One file for everything

The API returns one artwork URL per title.

CSS scales it to the card.

Pros

  • Simplest API and cache.

Cons

  • Too big for cards, too small for the hero and 4K screens.
  • Wastes bandwidth and memory on every card.

The browser picks with srcset

An image service makes widths such as 240, 360, 480, and 720 on demand.

The card uses picture with AVIF and WebP source elements, and srcset plus sizes so the browser picks a width for its screen.

Pros

  • The browser knows the real layout width and pixel ratio.
  • Format fallback is automatic.

Cons

  • Many URL variants to cache at the CDN.
  • TV renderers often don't support srcset or picture.

The server picks per device

The client sends its device class, screen size, and supported formats once.

The browse API returns exact artwork URLs for this device.

Pros

  • Works on any renderer, including TV engines without srcset.
  • One URL per card: simple prefetching and caching.

Cons

  • Wrong if the window is resized a lot on desktop.
  • The server must know each device's layout.

What we pick: Both of the last two, per platform. The web client uses picture with srcset, because desktop windows change size and the browser knows best. The TV client asks the server for exact sizes, because its screen and layout are fixed and its renderer may not choose for it. Both are backed by the same image service and CDN, which the image-optimization and cdn-caching lessons cover in depth.

The hero is the LCP element

The large billboard at the top is almost always the Largest Contentful Paint element. Give its image fetchpriority="high" and do not lazy load it. Keep the hero's trailer for later: the poster image paints first, and the trailer starts only after the page is idle and the member hasn't scrolled away. Cards below the first screen get loading="lazy", and cards in the first two rows load normally so they don't wait for layout.

Decoding and placeholders

When a row pages, 6 new images can appear at once. Decoding them on the main thread can drop frames during the slide. Two habits help.

First, prefetch and call img.decode() on the next page's images while the member is still looking at the current page, so the slide shows pixels that are already decoded. Second, give every card a placeholder colour sent in the API (a dominant colour per artwork), so a card that isn't ready yet looks like a tinted shape, not a white hole.

Personalized artwork

Netflix has written publicly about choosing different artwork for the same title for different members, picking among several images with a contextual bandit model (a kind of algorithm that learns from which choices lead to plays). A member who watches a lot of romance might see a romantic scene for a film, while another sees its lead actor. The exact model and signals are Netflix's own; what matters for the frontend is how such a system shapes the client.

  • The server chooses, the client shows. Each title in a row arrives with an artworkId and its URLs. The client never picks art itself.
  • The images stay public and cacheable. Only the choice is personal. Each image is a normal CDN file shared by every member who gets that choice, so personalization doesn't hurt CDN hit rates.
  • Stay consistent within a session. If the same title appears in two rows, show the same art in both, or the member may not recognize it. The client keys artwork by title in its cache, and the server should send the same choice for a session.
  • Report what was shown. The learning loop needs to know which art was on screen and whether it led to a play. The client logs an impression with titleId, artworkId, rowId, and position when a card has been at least half visible for about a second.
  • Localized art. Title logos are often part of the image and are translated per language, so the artwork choice also depends on the profile's language.

Check your understanding · Exercise 3

Which statements about artwork on the browse page are true? Select all that apply.

Hover and focus previews

On desktop, resting the mouse on a card makes it grow into a larger card with buttons, and after a moment a muted trailer plays inside it. On TV, the focused title's trailer plays in the big area at the top. Members like it when it works and hate it when it lags or blares, so it needs real design.

The naive version listens for mouseenter, creates a video element, and starts playing. Picture a member moving the mouse from the left of a row to the right to reach the arrow. In under a second the pointer crosses 6 cards.

Each one creates a video, fetches a manifest, starts a decoder, and downloads the first few seconds of video at maybe 1 to 2 MB each. That's around 10 MB of wasted downloads, 6 hardware decoders fighting over a TV that may only have one or two, and a flicker of half-started videos.

Decision When should a preview start, and what should load before it?

Play on enter

Create and play a video on mouseenter or focus.

Pros

  • Feels instant when it works.

Cons

  • Every pass of the pointer starts downloads.
  • Many decoders at once; flicker.

Delay, then load everything

Start a timer on enter, about 500 ms.

When it fires, expand the card and start loading the trailer from zero.

Pros

  • Sweeps across a row cost nothing.

Cons

  • After the delay the member still waits for manifest, first segment, and decoder start: another 1 to 2 seconds.

Staged intent with prefetch and abort

Stage 1, after about 400 ms resting: expand the card and prefetch the preview manifest and first segment with an AbortController.

Stage 2, about 1 second later if still there: attach the data to a single shared muted player and play.

Leaving at any stage aborts what is in flight.

Pros

  • Sweeps cost nothing; a real rest gets near-instant video.
  • Only one preview player ever exists.
  • Wasted bytes are capped at one manifest and one small segment per expanded card.

Cons

  • A state machine with timers and cancellation to get right.
  • Two delays to tune with experiments.

Animated image instead of video

Show a short animated WebP or AVIF loop instead of a trailer.

Pros

  • No video decoder, no player.

Cons

  • Large files for poor quality, no sound option, and still motion that must respect user settings.

What we pick: Staged intent with prefetch and abort. The first delay filters out a pointer that is passing through. The second stage only attaches data that is already downloaded, so the trailer starts within a few hundred milliseconds of the decision to play. The exact delays are product choices to tune with A/B tests (see the ab-testing lesson), not constants to guess.

State machine for a card preview. Idle goes to intent when the pointer enters or the card gets focus. Intent goes to expanded after 400 ms; expanded prefetches the manifest and first segment. Expanded goes to playing about 1 second later, using the one shared video. If previews are not allowed, expanded goes to still, which shows art and a play button; playing also goes to still when the trailer ends. From any state, leaving, Escape, or the tab being hidden aborts downloads and returns to idle.idleintenttimer runningexpandedprefetchingplayingshared videostillart + play buttonenter400 ms+1 snot allowedendedon leave, Escape,or tab hidden:abort, go to idle
Every download starts in expanded and carries one AbortController, so leaving at any moment costs at most a manifest and one small segment.

One shared preview player

Keep a single preview video element for the whole page, owned by a preview controller, not by cards. When a card reaches the playing stage, the controller moves that element into the card's slot (or positions it over the card) and plays. When another card wins, the same element is reused. This caps decoders at one, avoids the cost of creating media elements, and makes "stop all previews" a single call when a dialog opens or the tab is hidden.

ts
let attempt: { titleId: string; abort: AbortController; timers: number[] } | null = null;

function onIntent(titleId: string) {
  cancel();
  const allowed = previewsAllowed(); // setting, reduced motion, Save-Data
  const abort = new AbortController();
  const expand = window.setTimeout(() => {
    showExpandedCard(titleId);
    if (allowed) prefetchPreview(titleId, abort.signal); // manifest + first segment
  }, 400);
  const timers = [expand];
  if (allowed) {
    timers.push(window.setTimeout(() => {
      playShared(titleId, abort.signal).catch(() => showStill(titleId));
    }, 1400));
  }
  attempt = { titleId, abort, timers };
}

function cancel() {
  if (!attempt) return;
  attempt.timers.forEach((t) => clearTimeout(t));
  attempt.abort.abort(); // stops downloads in flight
  stopShared();
  attempt = null;
}
The preview controller's core: timers for the two stages, and one AbortController per attempt.

Autoplay rules and member choice

  • Muted by default. Browsers allow muted autoplay without a click, but block autoplay with sound until the user has interacted with the page. video.play() returns a promise that rejects with NotAllowedError when blocked, so always handle it and fall back to the still image.
  • A mute toggle on the card sets a session preference: once a member unmutes one preview, later previews keep sound, which is now allowed because they clicked.
  • Respect the setting. Netflix lets members turn off autoplay of previews per profile. The flag comes with the profile and previewsAllowed() checks it first.
  • Respect prefers-reduced-motion: reduce. Moving video that starts by itself is exactly what that setting asks to avoid. Show the still and a play button instead.
  • Respect data saving. If navigator.connection.saveData is true (Chromium only), or the measured bandwidth is very low, skip previews.
  • Stop when hidden. On visibilitychange to hidden, or when a modal opens, cancel the attempt.

Prefetching for the real play

A hover is also a strong signal that the member might press play. While the expanded card is open, the client can warm what the real player needs: open a connection to the video CDN with preconnect, fetch the title's playback metadata (manifest URL, resume position, available tracks), and on browsers that allow it, request the DRM license ahead of time. It should not download video segments for the main title; that would be too much waste per hover. The prefetching lesson covers how to judge intent signals like this one.

Sequence diagram of previews between the member, a card, the preview controller, and the video CDN. The member rests on card 7, the card calls onIntent, and after 400 ms the controller prefetches title 7. The member leaves at 600 ms, the card calls cancel, and the controller aborts the title 7 request. The member then rests on card 8; the controller prefetches title 8, the CDN returns the manifest and first segment, and at 1.4 seconds the controller plays the shared muted video in card 8.MemberCardPreview ctrlVideo CDNrests on card 7onIntent(t7)400 ms: prefetch t7leaves at 600 mscancel()abort t7rests on card 8400 ms: prefetch t8manifest + segment1.4 s: play muted
The failure path is the common one: most rests are short. cancel() clears the timers and aborts the request in flight, so the network is free for the card the member does stop on.

Check your understanding · Exercise 4

A member sweeps the mouse across 6 cards in 800 ms, then rests on the seventh. With the staged preview design, what happens? Select all that apply.

TV and the remote: spatial navigation

On a TV the member holds a remote with Up, Down, Left, Right, OK, and Back, maybe Play/Pause. There is no pointer to hover and no wheel to scroll. Every key press must move focus to the element the member expects, and the screen must scroll so the focused element is visible. This is called spatial navigation: moving focus by direction on a 2D screen, rather than through a 1D list as Tab does.

The naive answer is to let the platform handle it. Desktop browsers move focus with Tab in document order; arrows scroll the page.

Map Right to "next focusable" and Left to "previous", and Down to... what? In document order, the element after the last card of row 1 is the first card of row 2. So getting from the fourth card of row 1 to the fourth card of row 2 would take 75 presses of Right.

A CSS spatial navigation proposal exists as a draft, but browsers don't ship it, and many TV apps don't run in a normal browser DOM at all. The app has to own focus.

Decision How should the app decide where focus goes on each arrow key?

Pure geometry

On each key, measure every focusable element's rectangle.

Pick the nearest one in the pressed direction, scoring distance and overlap.

Pros

  • Works for any layout without extra structure.
  • Handles odd shapes like a hero with buttons.

Cons

  • Measuring hundreds of rectangles per press is slow on a TV.
  • Forgets context: pressing Down then Up may land on a different card than you started from.
  • Virtualized cards that aren't rendered can't be measured.

A focus tree with memory

The page is a tree: page, then rows, then cards; a row is a horizontal container, the page a vertical one.

Left and Right move within the row's index; Up and Down move to the neighbouring row.

Each row remembers its last focused index.

Pros

  • Cheap: a few integer updates per press, no measuring.
  • Predictable: Up after Down returns you to the same card.
  • Works with virtualization, since it moves by index, not by DOM node.

Cons

  • Every layout needs its tree defined.
  • Irregular areas (hero buttons, a menu) need their own containers.

Tree first, geometry as fallback

Use the focus tree for rows and grids.

Inside irregular containers, or when moving between containers, choose by geometry among a small set of candidates.

Pros

  • Fast and predictable where most presses happen.
  • Flexible for the few odd places.

Cons

  • Two mechanisms to test.

What we pick: Tree first, geometry as fallback. Most presses on a browse page are inside rows or between rows, where the tree is exact and costs almost nothing. Geometry is kept for the top menu, the hero's buttons, and the details screen. Open-source libraries such as Norigin's spatial navigation for React follow a similar idea of focusable containers; whether you adopt one or write your own, the model is the same.

Entering a row

Use the remote buttons, or focus one and press the arrow keys, OK (Enter), and Back (Escape).

A TV browse page with a hero and four rows of eight cards, four visible per row. Rule for entering a row: restore. Focus: Continue, Cont 1. Remembered cards: Continue 1, Trending 1, Comedies 1, Dramas 1.heroPlayMore infoContinueCont 1Cont 2Cont 3Cont 4+4TrendingTrnd 1Trnd 2Trnd 3Trnd 4+4ComediesCom 1Com 2Com 3Com 4+4DramasDra 1Dra 2Dra 3Dra 4+4rule: restore · focus: Continue, Cont 1
Use the remote buttons, or focus one and press the arrow keys, OK (Enter), and Back (Escape).

Focus memory between rows

Say focus is on card 5 of "Trending Now" and the member presses Down to "Comedies", which they last left on card 2. There are two sensible rules. Restore: go to the card that row remembers (card 2). Align: go to the card in the same screen column (card 5's column).

Netflix-style TV interfaces usually keep the focused card at a fixed position near the left edge and slide the row under it, which makes both rules land in the same screen spot and keeps the eye still. Our design uses restore with a fixed focus position: each row's remembered index is stored in the same map that holds its page index on the web.

ts
type BrowseFocus = { row: number; colByRow: Map<string, number> };

function move(f: BrowseFocus, key: string, rows: { id: string; length: number }[]) {
  const row = rows[f.row];
  const col = f.colByRow.get(row.id) ?? 0;
  if (key === "ArrowRight") f.colByRow.set(row.id, Math.min(col + 1, row.length - 1));
  if (key === "ArrowLeft") f.colByRow.set(row.id, Math.max(col - 1, 0));
  if (key === "ArrowDown") f.row = Math.min(f.row + 1, rows.length - 1);
  if (key === "ArrowUp") f.row = Math.max(f.row - 1, 0); // past row 0 goes to the hero
  return f; // the renderer scrolls so rows[f.row] and its column are on screen
}
The browse focus model is two integers and a map. No DOM is read on a key press.

Keys, repeats, and Back

  • Holding a key repeats it, often 10 to 30 times a second. If every repeat triggers a row slide animation, the TV falls behind and keeps moving after the member lets go. Apply moves to state immediately, but let the renderer skip to the latest position instead of playing every animation, and drop repeats that arrive while a move is still pending.
  • Back is not standard. Each TV platform sends its own key: for example, LG webOS sends key code 461 and Samsung Tizen sends 10009. A small platform layer maps these to one back action.
  • Back has a ladder of meanings: close the open dialog; otherwise return focus to the first card of the row; otherwise scroll to the top; otherwise open the menu; only then ask to exit. Members press Back repeatedly to "get out", and each press should do something visible.
  • OK on a card opens details; Play on the remote can start the title directly.

The 10-foot UI and TV performance

A TV is viewed from about 3 metres, so text and targets are much larger than on desktop: body text around 24 to 28 pixels on a 1080p layout, few words per card, and a focus state that is obvious from across the room (a scale-up plus a thick, high-contrast outline, not a subtle colour change). Keep important content inside a safe area of about 5 percent from each edge, since some TVs still crop the edges.

TV chips are far slower than laptops, and their browsers or engines are older. Netflix has described running its TV interface on its own rendering layer, called Gibbon, with React on top, rather than a full browser DOM, precisely to control performance on such devices. For an interview, the lessons transfer to any TV stack: keep the rendered tree tiny (fewer cards than desktop), animate only transforms and opacity, keep images in a strict memory budget with a least-recently-used cache, and measure on the slowest device you support.

Check your understanding · Exercise 5

Which choices make D-pad navigation on the browse page fast and predictable on a slow TV? Select all that apply.

The title details view

Clicking a card (or pressing OK on TV) opens the title's details: a header with the trailer or hero art, play and add-to-list buttons, the synopsis, cast, maturity rating, a season picker with episodes, and a grid of similar titles. The question is what happens to the browse page underneath.

The naive approach navigates to a new route like /title/80057281, unmounts the browse page, and fetches the details. Closing it navigates back and the browse page mounts again from scratch. The member, who had scrolled 15 rows down and paged three rows sideways, is now at the top. Even if scroll restoration works, every visible image is decoded again and the rows that loaded lazily may load again.

Decision Should details be a separate page or a layer over the browse page?

Separate page

A route that replaces the browse page.

Pros

  • Simple routing, a real URL, easy to server-render.

Cons

  • Loses browse state unless carefully restored.
  • Feels slow: the browse page is rebuilt on close.

Modal with no URL

A dialog over the browse page, opened from component state.

Pros

  • Browse state is kept for free.

Cons

  • Can't share or bookmark a title.
  • The Back button leaves the browse page instead of closing the dialog.

Modal backed by the URL

Opening adds the title id to the URL as a query parameter with history.pushState.

The browse page stays mounted under a dialog.

Loading that URL directly shows the browse page with the dialog open, or a full title page for logged-out visitors.

Pros

  • Browse state kept; Back closes the dialog.
  • Shareable links.

Cons

  • Two rendering paths for the same content: dialog and full page.

What we pick: A modal backed by the URL. Netflix's own web app keeps the browse page underneath and puts the title id in the URL (at the time of writing, as a jbv query parameter on the browse URL), which is the behaviour members expect. Logged-out visitors and search engines get a server-rendered public title page with the same content, which is the only version that needs SEO. The modal-dialog guide covers the dialog itself; here we cover what is specific to Netflix.

What loads, and when

  • Instantly, from the card's data: title, artwork, rating, year, and the match or genre tags the card already had. The dialog opens with these in the same frame as the click, so there's no blank dialog.
  • From the hover prefetch: if the member hovered first, the playback metadata and the preview's first segment are already cached, so the header trailer continues instead of restarting. The shared preview element is handed from the card to the dialog header.
  • On open: synopsis, cast, and the current season's episodes, in one request of a few KB.
  • On demand: other seasons when picked in the season menu, and the similar titles grid when scrolled into view.

For a series, the play button needs to know which episode to play: the episode in progress, or the next one if the last was finished, or episode 1. That decision belongs on the server, which has the full viewing history. The details response carries a playTarget with episode id and start position, so the button label ("Resume S2:E4" or "Play S1:E1") is right on first paint.

Focus and keyboard

Use the native dialog element with showModal(), which makes the rest of the page inert and handles Escape. Move focus to the dialog's heading or Play button when it opens, and return focus to the card that opened it when it closes, even if that card was unmounted and remounted by virtualization in the meantime. Because cards are recreated, store the opener as rowId and titleId, not as a DOM node, and focus the matching card after closing. The focus-management lesson covers the general rules; virtualization is what makes this case tricky.

On TV, the details view is a full screen rather than a dialog, with its own focus tree: buttons in a row, then the season menu, then episodes as a vertical list. Back returns to the browse page with the same row and card focused.

Check your understanding · Exercise 6

A member opens a title's details from the 12th row, reads them, and closes the dialog. Which design details make that smooth? Select all that apply.

The player: adaptive streaming for long films

The YouTube guide builds the basic pipeline: the video is cut into segments of a few seconds at several qualities, a manifest lists them, and an adaptive bitrate (ABR) rule picks a quality for each segment, and the player uses Media Source Extensions (MSE) to append the segments it chooses into a SourceBuffer that feeds the video element. Read that first. Here we look at what changes for a subscription service that plays protected films and series, mostly on big screens.

  • Sessions are long. A member watches a 2-hour film or several episodes in a row. Stable quality over hours matters more than reacting to every small change in bandwidth.
  • Screens are big. On a 65-inch TV, the jump from 1080p to 4K is visible, and so is a drop to 480p. Quality switches are noticed.
  • Every segment is encrypted. The player needs a license before it can decode the first frame, which adds a step to startup.
  • Each title has its own ladder. Netflix described per-title encoding in 2015: a simple cartoon needs far fewer bits than a grainy action film at the same resolution, so each title gets its own set of bitrates. In 2018 it described going further, tuning the encode shot by shot with a framework it calls the dynamic optimizer.

Decision Which streaming format and container should the web player use?

Progressive MP4

One file per quality, played from a URL with range requests.

Pros

  • No player library needed.

Cons

  • No quality switching mid-play.
  • No practical DRM workflow on the web.

HLS everywhere

One HLS playlist set, played natively by Safari and through an MSE library like hls.js elsewhere.

Pros

  • One manifest format.

Cons

  • Outside Apple platforms it still needs an MSE library, and Widevine and PlayReady pair more naturally with DASH.

DASH and HLS over shared CMAF segments

Package video once as CMAF (Common Media Application Format) fragmented MP4 segments, encrypted with the cbcs scheme (AES in CBC mode with a pattern, the scheme FairPlay requires).

Publish a DASH manifest for Chromium and Firefox and an HLS playlist for Apple devices.

Both manifests point at the same segment files on the CDN.

Pros

  • One copy of the media on the CDN, two manifests.
  • cbcs encryption works with Widevine, PlayReady, and FairPlay on current devices.
  • Each platform gets its best-supported path.

Cons

  • Two manifest generators and two player paths to test.
  • Older devices, such as TVs with PlayReady versions before 4.0, may only support the older cenc (AES-CTR) scheme and need their own packaging.

What we pick: DASH and HLS over shared CMAF segments. It's the common industry setup for protected streaming because it stores media once and plays everywhere. Netflix runs its own player and formats, so treat this as our design, not a description of theirs.

The bitrate ladder

A bitrate ladder is the list of encoded versions of a title, from lowest to highest. The table shows an example ladder for a typical drama. A cartoon's ladder would reach 1080p at maybe half these bitrates, and the player doesn't care: it reads the ladder from the manifest.

An example ladder: our own round numbers. Segment size is bitrate times 4 seconds, divided by 8 for bytes.
RungResolutionVideo bitrateOne 4-second segment
1320 by 1800.25 Mbpsabout 125 KB
2640 by 3600.75 Mbpsabout 375 KB
3960 by 5401.5 Mbpsabout 750 KB
41280 by 7203 Mbpsabout 1.5 MB
51920 by 10805 Mbpsabout 2.5 MB
63840 by 216015 Mbpsabout 7.5 MB

Buffer goals for long-form video

The buffer is how many seconds of video are downloaded ahead of the playhead. A bigger buffer survives longer bandwidth drops, but it costs memory, and bytes are wasted if the member stops early. Web players often default to about 30 seconds ahead (hls.js's maxBufferLength is 30), which suits short clips.

For a film, a member who has watched 10 minutes will very likely watch more, so a desktop player can aim for a few minutes ahead once quality is stable. A TV with little memory might stop at 60 seconds. These goals are our example numbers, not Netflix's.

Browsers also cap how much a SourceBuffer can hold and throw a QuotaExceededError when it is full. So the player removes video more than about 30 seconds behind the playhead with SourceBuffer.remove(), and keeps that small window for quick rewinds.

Buffer goal

Set the bandwidth, pick a buffer goal, and press Next segment or Play 40 s.

Buffer and bitrate simulation. Bandwidth 8 Mbps, buffer goal 30 seconds. Buffer now 0 seconds. Last qualities: none yet. Stalls: 0, 0.0 seconds in total.seconds buffered ahead (scale 0 to 120)goal 30 slast segments: quality (red: it stalled), then buffer after ittime: 0 sstartup: not startednext rung guess: 360pstalls: 0stalled: 0.0 sbandwidth: 8 Mbps
Set the bandwidth, pick a buffer goal, and press Next segment or Play 40 s.

Try the simulator. At 8 Mbps with the 30-second goal, press Play 40 s three times: the player settles on 1080p, and the buffer stops near 30 seconds. Now drag the bandwidth down to 0.5 Mbps and press Next segment. The player still believes the old estimate and picks a 1080p segment, which takes 40 seconds to download: longer than the buffer, so playback freezes.

Reset, choose the 120-second goal, and repeat the same presses. The buffer is now past 50 seconds, so the same slow segment only shrinks it, and the player steps down before anything shows. A long buffer turns network trouble into a quality dip instead of a spinner. (Real players also abandon a download that is clearly too slow and refetch it at a lower rung; the YouTube guide covers that.)

Stable quality over fast reactions

  • Switch up slowly, switch down fast. Step up one rung only after throughput has stayed above the next rung with a margin (say 1.4 times its bitrate) for 10 to 20 seconds. Step down as soon as the buffer is falling and the estimate is below the current rung.
  • Let the buffer decide when it's healthy. Above 60 seconds of buffer, a short dip doesn't matter, so ignore it. Buffer-based rules, studied in a 2014 paper with Netflix experiments, select quality mainly from buffer level for this reason.
  • Never exceed the screen. Cap the rung at the player's size times devicePixelRatio, and at what the DRM level allows (next chapter).
  • Pick a codec once. Ask navigator.mediaCapabilities.decodingInfo(), including a keySystemConfiguration for encrypted content, whether AV1, HEVC, VP9, or H.264 will be smooth and power efficient here, and stay with that codec for the session.

Startup time: what happens after Play

Between pressing Play and the first frame, the player must get playback metadata (manifest URL, tracks, resume position), the manifest itself, a DRM license, the init segment, and the first media segment, then decode. Done one after another, with 100 ms round trips, new connections to each server, and no hover prefetch, that's easily 2 to 3 seconds.

Done well, it's under 1 second: the metadata was prefetched on hover, the license request runs in parallel with segment downloads, the first segment comes from a low rung (rung 2 or 3, about 400 to 750 KB) starting at the resume position, and the player begins as soon as one segment is buffered.

Startup timeline from pressing Play to the first frame, with steps overlapped. Playback metadata is already cached from the hover. The manifest takes 0 to 150 ms. The license request runs from 150 to 500 ms, in parallel with the init segment from 150 to 300 ms and the first segment at rung 2 from 300 to 700 ms. The first frame shows at about 800 ms. Run one after another, the same steps take about 1.25 seconds, before counting new connections or an uncached metadata call.metadatacached from hovermanifestlicensebeside the segmentsinit segmentfirst segmentrung 2first frame0500 ms1 sin sequence: 1.25 soverlapped: 0.8 s
The license and the first segment don't depend on each other, so they run side by side. Starting on a low rung keeps the first segment small; quality climbs once playback has started.

Check your understanding · Exercise 7

Members on home Wi-Fi report occasional stalls during films, but rarely during short trailers. Which changes are most likely to help? Select all that apply.

DRM, subtitles, and audio tracks

Studios license films on the condition that they're protected. DRM (digital rights management) means the segments on the CDN are encrypted, and only a trusted piece of software in the browser or device, called the CDM (content decryption module), can get the keys and decrypt them. The page's JavaScript never sees the keys or the decrypted frames. The web API that connects the page to the CDM is Encrypted Media Extensions (EME), a W3C standard supported by all major browsers.

Three DRM systems cover nearly every device. Each browser ships with one or more of them, and the player must ask which are available, in order of preference.

DRM systemKey system stringWhere you find it
Google Widevinecom.widevine.alphaChrome, Firefox, Edge, Android, Chromecast, many smart TVs
Microsoft PlayReadycom.microsoft.playreadyEdge on Windows, Xbox, many smart TVs
Apple FairPlaycom.apple.fpsSafari on macOS and iOS, Apple TV

The systems also differ in how well they protect the content. A CDM that decrypts and decodes in secure hardware is trusted more than one that runs in software. Studios typically allow the highest resolutions only with hardware-backed protection, which is why streaming services publish different maximum qualities per browser and operating system.

Netflix's help pages list such limits per browser. The player reads the allowed maximum from the license or the playback metadata and caps its ladder to it.

The EME flow, step by step

ts
const config = [{
  initDataTypes: ["cenc"],
  videoCapabilities: [{ contentType: 'video/mp4; codecs="avc1.640028"', robustness: "HW_SECURE_ALL", encryptionScheme: "cbcs" }],
}];
const access = await navigator.requestMediaKeySystemAccess("com.widevine.alpha", config);
const mediaKeys = await access.createMediaKeys();
await video.setMediaKeys(mediaKeys);

const session = mediaKeys.createSession();
session.addEventListener("message", async (event) => {
  const res = await fetch(licenseUrl, {
    method: "POST",
    body: event.message, // opaque challenge from the CDM
    headers: { Authorization: `Bearer ${playbackToken}` },
  });
  if (!res.ok) return showLicenseError(res.status, await res.json()); // e.g. STREAM_LIMIT_REACHED
  await session.update(await res.arrayBuffer()); // keys go into the CDM
});
await session.generateRequest("cenc", psshFromManifest); // PSSH: the DRM's init data, from the manifest
Choose a key system, then answer the CDM's license request through our own license server.

In practice you try configs in order (hardware robustness first, then software; Widevine, then PlayReady) and keep the first that resolves. Player libraries such as Shaka Player wrap this flow, but you should be able to explain it. The license request is the step where business rules live: the server checks the member's plan, the region's rights for this title, and the number of streams already playing on the account. A refusal must become a clear message, not a black screen.

Sequence diagram of the DRM license flow between the player's JavaScript, the browser's CDM, and the license server. The player calls requestMediaKeySystemAccess to ask for Widevine and gets MediaKeys. It calls generateRequest with the PSSH from the manifest, and the CDM replies with a message holding a challenge. The player posts the challenge and a playback token to the license server, which returns license bytes, and the player passes them to session.update. On the failure path, the license server answers 403 STREAM_LIMIT_REACHED and the player shows a message about the stream limit.Player JSBrowser CDMLicense serverrequest Widevine accessMediaKeys (Widevine)generateRequest(pssh)message: challengePOST challenge + tokenlicense bytessession.update(license)or: 403 STREAM_LIMITplayer shows the stream limit message
JavaScript only carries opaque bytes between the CDM and the server; keys never reach the page. The license call is also where the server says no, so its error codes need clear messages.

The player as a state machine

Add DRM to loading, buffering, seeking, and errors, and a pile of booleans like isLoading, hasLicense, isStalled soon allows impossible combinations. Model the player as one explicit state machine instead: each state lists which events it accepts, and the UI renders from the state alone. The state-machines lesson covers the technique.

State machine for the player. Idle goes to loading on Play; loading fetches the manifest, the license, and the first segment. Loading goes to playing at the first frame, or to error if the license is refused. Playing and paused switch on pause and play. Playing goes to rebuffering when the buffer is empty and back when it is filled. Playing goes to seeking on a seek and back when the seek lands. Playing goes to ended at the end. Seek waits are not counted as rebuffering.idleloadingmanifest, license, segmenterrorclear messagepausedplayingrebufferingseekingendedplayrefusedfirst framepauseplayemptyfilledseeklandedendseek waits are notcounted as stalls
The controls render from one state, so impossible mixes like playing and loading at once can't happen. Keeping seeking and rebuffering apart is also what keeps the rebuffer metric honest.

Subtitles and captions

Netflix offers subtitles and closed captions (subtitles plus sound descriptions such as "[door creaks]") in many languages. They are fetched as separate timed-text files, in timed-text formats such as WebVTT or TTML/IMSC (each cue is text with a start and end time), only for the language the member picks.

The player can hand them to the browser as a track element, but many services render them in their own overlay above the video, for three reasons: the same look on every device, member styling (size, colour, background) that applies everywhere, and moving captions up when the controls appear so they're never covered. The overlay updates on the video's timeupdate or, more precisely, from a requestVideoFrameCallback loop where supported.

Audio languages and audio description

Audio is its own stream, separate from video, so the same video segments serve every language. The manifest lists audio tracks by language and kind: original, dubbed, and audio description (narration of what's on screen for blind and low-vision members). Switching language mid-play means the player clears the audio SourceBuffer ahead of the playhead and fetches the new track from the current time, a short pause at most. The profile remembers the chosen audio and subtitle languages per title and as a default, so the next episode starts with the same choices.

Check your understanding · Exercise 8

Which statements about EME and DRM in the browser are true? Select all that apply.

Resume, Continue Watching, skip intro, and the next episode

A member stops an episode on the living room TV at 31:40, then opens Netflix on a laptop in bed. The Continue Watching row should show that episode first with its progress bar at about 60 percent, and Resume should start at 31:40, or a few seconds before. This one feature touches the player, the browse page, the API, and caching.

The naive approach saves the position when the member presses Stop or closes the player. But members rarely press Stop. They turn off the TV, close the laptop lid, or lose the connection, and the save never runs. The next device then resumes from where the previous session started.

Decision How should the player report viewing progress?

Save on stop only

One request when the player closes.

Pros

  • Very few requests.

Cons

  • Lost whenever the device turns off, crashes, or goes offline.

Save every few seconds

A request every 5 seconds while playing.

Pros

  • At most 5 seconds lost.

Cons

  • Millions of members times one request per 5 seconds is a heavy, mostly useless load.

Heartbeats plus event saves

A heartbeat every 30 to 60 seconds while playing.

An extra save on pause, seek, track change, episode end, and when the page is hidden.

The final save uses fetch with keepalive: true or navigator.sendBeacon so it survives the page closing.

Pros

  • Saves exactly at the moments a member is likely to leave.
  • Low steady load, and at most one heartbeat interval lost after a power cut.

Cons

  • Needs care with ordering when two devices play the same profile.

What we pick: Heartbeats plus event saves. The same heartbeat also carries playback quality data for monitoring (see the observability chapter), so one stream of small requests serves both resume and quality.

Rules that make resume feel right

  • Each save carries a timestamp and a session id. The server keeps, per profile and title, the save with the latest timestamp. If two devices play the same episode at once, the most recent activity wins.
  • Rewind a little on resume. Starting 3 to 5 seconds before the saved point helps the member remember where they were.
  • Finished means near the end, not at the end. Once the credits start (a marker in the metadata), or past about 95 percent, the title counts as watched. For a series, the play target moves to the next episode, which is what Continue Watching should show.
  • Fresh row on return. When the member comes back to the browse page after playing, update the Continue Watching row in the local cache from the last save straight away, then refetch it. Don't wait for a full browse refresh.
  • Fresh row on another device. The Continue Watching row has a short cache lifetime (seconds to a minute), unlike other rows that can be cached for much longer, because it changes whenever any device in the household plays something.
Sequence diagram of resuming across devices, between the TV player, the BFF, and a laptop. The TV sends a heartbeat at 31:10, and the BFF answers 204. The member pauses, and the TV saves 31:40. Then the TV is switched off at the wall and sends nothing more; the last save stands. The laptop asks for Continue Watching and gets season 2 episode 4 at 31:40. It asks for playback metadata and gets the manifest and a token, starting at 31:36, a few seconds earlier.TV playerBFFLaptopheartbeat: 31:10204pause: save 31:40TV switched off:no more savesGET Continue WatchingS2:E4 at 31:40GET playback, start 31:36manifest + token
The pause save is what makes this exact. Without it, the laptop would resume from the last heartbeat, at most 30 to 60 seconds earlier, which is still acceptable.

Skip intro and skip recap

The playback metadata includes time markers per episode: intro start and end, recap start and end, and credits start. The player compares the current time to these ranges on each timeupdate and shows a Skip Intro button inside the intro range. On TV, the button takes focus when it appears, so a single OK press skips; when the intro ends, focus returns to nothing and the controls hide.

Skipping is a seek to the intro's end, and the player can prefetch the segment at that point when the intro begins, so the skip lands on video that's already buffered.

The next episode countdown

When the credits marker is reached, the player shrinks the video into a corner and shows the next episode with a countdown of a few seconds. Pressing Next or letting the countdown end plays it; any other input cancels the countdown and lets the credits play.

Starting the next episode fast is easy if the player has already prepared it during the credits: fetch its playback metadata and license, and buffer its first segment at a low rung. After a number of episodes with no input at all, Netflix asks "Are you still watching?" before continuing, which saves bandwidth for members who fell asleep.

Check your understanding · Exercise 9

A member watches on a TV and then switches it off at the wall. What does the heartbeat design guarantee? Select all that apply.

Profiles and kids mode

One account, up to 5 profiles, all sharing devices. Each profile has its own rows, history, Continue Watching, My List, language, subtitle style, preview setting, and maturity limit. A kids profile shows only titles rated for children and a simpler, brighter interface. A profile can have a 4-digit lock so children can't open a parent's profile.

The frontend risk is leaking one profile's data into another. Imagine the client caches the browse response under the key browse. A parent switches from their profile to the kids profile, and for a moment, until the refetch lands, the kids profile shows the parent's rows, artwork and all. On a kids profile, that's a serious bug, not a cosmetic one.

Scope everything by profile

  • Cache keys include the profile id: ['browse', profileId], ['title', titleId, profileId]. A switch can never read the other profile's entry.
  • Switching clears and aborts. On switch, abort every request in flight, stop the preview, drop the in-memory cache for the old profile, and reset the browse store (row pages, focus memory). Show the profile's loading screen, not the old content.
  • The server enforces maturity. The client never filters titles by rating itself; it only shows what the API returns for that profile. Client-side filtering would mean a kids profile downloads adult titles and artwork and merely hides them.
  • The lock is checked on the server. The PIN is sent to an API that returns a profile session. A PIN check in JavaScript would be trivial to bypass.
  • Other tabs follow. The selected profile is part of the session cookie, so a switch in one tab affects the next request in every tab. Post a message on a BroadcastChannel so other open tabs reload into the new profile instead of mixing two profiles' data.

Kids mode is a theme and a ruleset, not a second app

The kids interface looks different: bigger artwork, characters as row headers, no mature artwork, no search results outside kids titles. It's tempting to build it as a separate app.

It's cheaper to keep one browse page and one player, with the profile's type choosing a theme (design tokens for colours, sizes, and fonts) and a set of feature flags (for example, previews off by default, no title ratings shown). The data rules stay on the server. The feature-flags lesson covers how to keep such flags manageable.

Think first

Images are cached by the browser's HTTP cache, which knows nothing about profiles. Does a parent's artwork in the HTTP cache put the kids profile at risk?

Check your understanding · Exercise 10

A parent switches from their profile to a kids profile. Which steps prevent the kids profile from ever showing the parent's content? Select all that apply.

Architecture: the pieces and their boundaries

With the hard parts decided, we can draw the client. The guiding idea is that the fast-changing machinery (the player's segment loop, the focus engine's key handling, the preview timers) lives in plain TypeScript modules outside the React tree. React components subscribe to the few facts they display. A segment append every 4 seconds or a key repeat 20 times a second should never re-render the browse page.

Architecture of the Netflix web client. Inside the browser: Telemetry sends impressions and heartbeats through the API client, which keeps a cache keyed by profile and calls the BFF. The API client feeds rows to the Browse page. The Browse page hands hovered cards to the Preview controller, which owns one shared video and fetches from the Video CDN. Card images come from the Image CDN. The Focus engine turns keys into focus moves on the Browse page and keeps positions in the Browse store. The Player controller runs ABR, MSE, and EME, fetching segments from the Video CDN and licenses from the License server. The App shell handles routes and the profile gate.BrowserTelemetryimpressions, beatsAPI clientcache by profileBrowse pagerows, cards, heroPreview ctrlone shared videoFocus enginekeys to focusPlayer ctrlABR, MSE, EMEBrowse storepages, focus memoryApp shellroutes, profile gateBFFrows, detailsImage CDNVideo CDNsegmentsLicense serverDRM
The fast-moving parts (focus, previews, the player) are plain modules beside the React tree, not inside it. Only page data goes through the BFF; video and images come straight from CDNs.
PieceResponsibilityWhy it's separate
App shell and routerProfile gate, routes for browse, search, and watch; the details dialog as URL state.Profile switching and auth checks happen in one place before any page loads data.
Browse pageVertical row window, hero, row placeholders, lazy row loading.Owns layout only; data comes from the cache, positions from the browse store.
Row and CardHorizontal window, paging, card art and progress bar.Rendered hundreds of times, so they are kept small and memoized.
Browse storeRow page index, TV focus memory, scroll position, keyed by row id.Survives row unmounting and Back navigation.
Focus engineTurns remote keys into focus moves on the focus tree; geometry fallback.Works on indices, so it doesn't care which cards are rendered.
Preview controllerIntent timers, prefetch with abort, one shared preview video.One owner for the only video decoder the browse page may use.
Player controllerState machine, ABR engine, segment fetcher, MSE adapter, DRM manager, timed-text renderer.Runs outside React; the controls subscribe to its state.
API client and query cacheRequests to the backend-for-frontend, cache keyed by profile, retries.One place for auth, profile scoping, and deduplication.
TelemetryImpressions, playback heartbeats, errors, Web Vitals.Batched and sent with sendBeacon so it never competes with video.

What the client talks to

  • A backend-for-frontend (BFF) for all page data: browse rows, details, playback metadata, profile settings, writes like My List and ratings. It shapes responses per device type, so the TV gets fewer, exact-size artwork URLs and the web gets srcset variants. Netflix's apps long fetched data through Falcor, its own JSON Graph library, and Netflix has since written about moving to a federated GraphQL API (from 2020) and migrating its mobile apps off Falcor. A GraphQL gateway or a REST BFF both fit this role.
  • An image CDN that serves artwork in several widths and formats.
  • A video CDN that serves manifests and segments. Netflix runs its own, called Open Connect, with servers placed inside internet providers' networks so video travels a short distance.
  • A license server for DRM, which the player calls through the BFF or directly with a short-lived playback token.

Why split it this way? The BFF exists because the browse page needs data from many services (ranking, artwork choice, viewing history, ratings) and a TV can't afford a dozen round trips. The license server stays separate because its rules and keys are security-critical and change on their own schedule. Video bytes never touch the BFF: they come straight from the CDN, because that's where the scale is.

Data flow for the browse page. The API client sends GET /browse to the BFF, which returns ranked rows as JSON. The API client normalizes them into the query cache, with titles stored by id. The browse page reads row ids from the cache and renders its window of rows; each row passes titles to its cards. Cards request images from the Image CDN, which returns AVIF or WebP bytes. A click on My List in a card patches the cache at once, sends PUT /my-list to the BFF, and rolls back if it fails.BFFranked rowsAPI clientauth, profileQuery cachetitles by idJSONGET /browsenormalizeImage CDNAVIF, WebPCardart, progressBrowse pagerow windowrow idstitlesimg, bytesMy List clickoptimistic1. patch the cache2. PUT /my-list/t813. error: roll backwrites go backthrough the API client
Reads flow left to right and down: one JSON response fills the cache, and images bypass the BFF entirely. Writes update the cache first and confirm with the server after.

The data flow figure follows one browse visit: the shell requests the first chunk of rows, the response fills the query cache, rows render from the cache, cards request artwork from the image CDN, and writes such as adding to My List go back through the BFF with an optimistic update. The player has its own lane: metadata from the BFF, manifest and segments from the video CDN, the license from the license server, and heartbeats back to the BFF.

Check your understanding · Exercise 11

Why does the design keep the player controller and the focus engine outside the React tree? Select all that apply.

Data model: what lives where

The client holds four kinds of data, and each kind has a natural home. Server data that many screens share (titles, rows) goes in a normalized query cache. UI positions that must survive unmounting (row pages, focus) go in a small browse store mirrored to history.state.

Things that should be shareable go in the URL. Player state lives in the player controller. Very little needs to persist on the device.

Normalization matters here because one title shows up in many places: in Trending Now, in Because You Watched X, in Continue Watching, in the details dialog, and in My List. If each row carried its own full copy, adding the title to My List would need updating five copies. Instead, rows hold title ids, and titles are stored once by id. A row is a list of ids plus a cursor for the next page.

ts
type Title = {
  id: string;
  name: string;
  kind: "movie" | "series";
  maturity: string;          // "TV-14", shown, never filtered on the client
  artwork: { artworkId: string; placeholderColor: string; srcset: string };
  previewManifestUrl?: string;
};

type Row = {
  id: string;
  label: string;             // "Because you watched Dark"
  kind: "standard" | "continue-watching" | "top-10";
  titleIds: string[];        // loaded so far
  nextCursor: string | null; // more titles in this row
};

type TitleProgress = { titleId: string; episodeId?: string; positionSec: number; durationSec: number; updatedAt: number };

type BrowseUiState = { pageByRow: Record<string, number>; focus: { row: number; colByRow: Record<string, number> }; scrollY: number };
Client entities. Rows reference titles by id; per-profile facts sit on the profile's view of a title.
The client data model, grouped by where data lives. In the query cache, keyed by profile: Row with title ids and a next cursor points to Title, which has name, artwork, and maturity; TitleProgress with position and update time points to Title; Details with play target and seasons points to Title. The browse store holds BrowseUiState with row pages and focus. The URL holds the open title, like question mark title equals t81. localStorage on the device holds the last bandwidth estimate and the volume. Player memory holds the buffer and ABR state.Query cache, keyed by profileRowtitleIds, nextCursorTitlename, art, maturityDetailsplayTarget, seasonsTitleProgressposition, updatedAtBrowse storeBrowseUiStatepages, focusURL?title=t81localStorage, per devicebandwidth, volumePlayer memorybuffer, ABR state
Titles are stored once and referenced by id from rows, progress, and details. Anything that must follow the profile lives on the server and in the cache; the device keeps only facts about itself.
DataWhere it livesLifetime
Rows, titles, detailsQuery cache, keyed by profile idMinutes; the Continue Watching row only seconds
Row pages, focus, scrollBrowse store plus history.stateThe browse visit, restored on Back
Open details dialogURL query parameterShareable, survives reload
Player state, buffer, ABR estimatePlayer controller in memoryThe playback session
Last bandwidth estimate, volumelocalStorage per deviceWeeks; device facts, not profile facts
Selected profileSession cookie set by the serverThe sign-in session

Notice what isn't stored on the device: viewing history, My List, and resume positions all live on the server, because they must follow the profile across devices. The device keeps only facts about itself, like how fast its network was last time.

Check your understanding · Exercise 12

Which pairings of state and home are right? Select all that apply.

Interface: the API the client needs

The client needs five groups of calls: browse rows, title details, playback metadata, the license, and writes (progress, My List, ratings). Shape them around screens, not around backend services, and paginate in two directions: rows down the page, and titles within a row.

http
GET /api/browse?rows=8&perRow=12&cursor= HTTP/1.1
Cookie: session=...; profile=p_42
Accept: application/json

200 OK
Cache-Control: private, max-age=300
{
  "hero": { "titleId": "t_81", "previewManifestUrl": "https://..." },
  "rows": [
    { "id": "r_cw", "label": "Continue Watching", "kind": "continue-watching",
      "titleIds": ["t_12", "t_90"], "nextCursor": null, "maxAgeSec": 30 },
    { "id": "r_trend", "label": "Trending Now", "kind": "standard",
      "titleIds": ["t_81", "t_7", "..."], "nextCursor": "c_row_12" }
  ],
  "titles": { "t_81": { "name": "...", "artwork": { "artworkId": "a_3", "...": "..." } } },
  "nextRowsCursor": "c_page_8"
}
The first browse request returns the hero and the first rows, each with its first two pages of titles.
  • GET /api/browse?cursor=c_page_8&rows=6: the next chunk of rows, fetched when the sentinel nears.
  • GET /api/rows/r_trend/titles?cursor=c_row_12&count=12: more titles in one row, fetched when the member nears the last loaded page.
  • GET /api/titles/t_81: details, with playTarget (episode and start position) and the current season's episodes.
  • GET /api/playback/t_81?episode=e_4: manifest URLs per format, audio and text tracks, markers (intro, recap, credits), the allowed maximum quality, and a short-lived playback token for the license server.
  • POST /api/license with the CDM's challenge bytes and the playback token: returns the license bytes, or a typed error.
  • POST /api/progress: { titleId, episodeId, positionSec, sessionId, sentAt, quality } from heartbeats and event saves; returns 204.
  • PUT /api/my-list/t_81 and DELETE /api/my-list/t_81: optimistic on the client, rolled back on failure.

Errors the member can understand

Most errors on the browse page can be hidden: a row that fails is retried and then dropped. Playback errors can't, so the API returns a typed error with a code the client maps to a clear message and an action. The playback-related ones are the important set.

json
{
  "error": {
    "code": "STREAM_LIMIT_REACHED",
    "message": "Too many people are using this account right now.",
    "retryable": false,
    "details": { "activeStreams": 2, "planLimit": 2 }
  }
}
A license refusal: the client shows a message about the stream limit with a link to manage devices.

Other codes in the same family: NOT_AVAILABLE_IN_REGION, PROFILE_MATURITY_BLOCKED, DEVICE_NOT_SUPPORTED (the CDM can't meet the title's protection rules), and PLAYBACK_TOKEN_EXPIRED, which is retryable: the client fetches fresh playback metadata and tries again without bothering the member.

Check your understanding · Exercise 13

Which API choices fit the browse page's needs? Select all that apply.

Performance: the landing page and the browse page

There are two very different first loads. The logged-out landing page is a marketing page: the same for everyone in a country and language, read by search engines, and its job is to get a visitor to sign up. The browse page is private, personal, and heavy with images. They deserve different rendering strategies.

Decision How should each of the two first pages be rendered?

One client-rendered app for both

Send a JavaScript bundle; it fetches data and renders everything.

Pros

  • One codebase and one build.

Cons

  • The landing page waits for JavaScript to show text that never changes.
  • Search engines and link previews get an empty shell.

Server-render both fully and hydrate

Render all HTML on the server, then hydrate the whole page: React attaches its state and event handlers to that HTML in the browser.

Pros

  • Fast first paint everywhere.

Cons

  • The browse page is personal, so the HTML can't be cached at the CDN.
  • Hydrating 40 rows of cards is heavy work the member doesn't need.

Static landing, streamed browse shell

Landing page: static HTML per locale cached at the CDN, with very little JavaScript for the email field and FAQ.

Browse page: stream the shell, hero, and first two rows from the server with their data inlined; render later rows on the client as they load.

Pros

  • The landing page is cacheable and light.
  • The browse hero (the LCP element) appears without waiting for client JavaScript.

Cons

  • Two rendering paths to maintain.
  • The browse server must respond quickly, since its HTML isn't cacheable.

What we pick: Static landing, streamed browse shell. Netflix has described removing client-side React from its logged-out landing page: React still renders the HTML on the server, and plain JavaScript handles the few interactive parts in the browser. They reported about a 50 percent faster time to interactive. While the visitor reads the landing page, the browser can prefetch the JavaScript the sign-up flow will need, so the next step is fast too.

Core Web Vitals targets with real causes

MetricGood threshold (75th percentile)What threatens it hereFix
LCP2.5 s or lessThe hero image waits for JavaScript or competes with 36 card images.Server-render the hero img with fetchpriority="high"; lazy-load cards below the first rows.
INP (Interaction to Next Paint)200 ms or lessHover expansion triggers layout; a key press re-renders many rows.Expand with transform: scale(); keep focus moves out of React renders.
CLS (Cumulative Layout Shift)0.1 or lessRows and cards resizing when data or images arrive.Fixed row heights, aspect-ratio boxes for art, placeholders at final size.

Splitting the code by intent

The browse page doesn't need the player. The player bundle (ABR, MSE adapter, EME manager, timed-text renderer) can be 100 KB or more compressed, so it is its own chunk. Load it on intent: when a card is expanded or the details dialog opens, import() the player chunk in the background. By the time the member presses Play, the code is ready.

The details dialog and the search page are split the same way. The core-web-vitals lesson covers measuring these in the field.

Budgets on TV

  • Rendered cards: keep the window small, about 5 rows of 10 cards on screen and nearby, since every card is a texture in memory.
  • Image memory: an LRU cache of decoded artwork with a byte budget, evicting cards far from focus.
  • Animation: only transforms and opacity; no shadows or blurs that need repainting on each frame.
  • Startup: the app launches into the profile picker from a cached bundle; browse data loads while the member picks.

Check your understanding · Exercise 14

The browse page's LCP at the 75th percentile is 4.1 seconds, and the LCP element is the hero image. Which changes are likely to help? Select all that apply.

Accessibility: rows, previews, captions

Netflix's audience includes blind members who use screen readers, deaf members who rely on captions, members who can't use a mouse, and members who get motion sickness from moving video. The browse page and previews are where accessibility goes wrong most often, because they are built for a pointer.

Start with keyboard users on the web. If every card is a link and a tab stop, reaching the tenth row means pressing Tab dozens of times per row, and virtualization makes it worse: the cards a member could tab to keep changing. The page needs a structure that's fast to move through and that tells a screen reader where it is.

Decision How should keyboard and screen reader users move through rows on the web?

Every card is a tab stop

Each card is a link in normal tab order.

Pros

  • No custom key handling.

Cons

  • Hundreds of Tab presses to cross the page.
  • With virtualization, tab order jumps as cards mount and unmount.

A full ARIA grid

The page is a role="grid" with rows and cells; arrow keys move in two dimensions.

Pros

  • One pattern for the whole page.

Cons

  • A grid implies tabular data and announces row and column numbers that mean little here.
  • Headings for each row get lost inside the grid.

One tab stop per row, arrows inside

Each row is a section with an h2 and a list of links.

Only one card per row is in the tab order (roving tabindex); Left and Right move between cards and page the row as needed.

Tab goes to the next row; screen reader users can also jump by heading.

Pros

  • About 40 Tab presses cross the whole page, or heading jumps for screen readers.
  • Matches the D-pad model on TV, so both share the focus logic.

Cons

  • Custom arrow-key handling to build and test.
  • Not every user expects arrows; the row's buttons must stay reachable too.

What we pick: One tab stop per row, with arrows inside. It's the same pattern the keyboard-navigation lesson teaches for toolbars and listboxes, and it reuses the TV focus model: the focused card index per row is the same number in both. Rows keep real headings, so screen reader users can also jump between rows by heading.

Telling a screen reader about cards it can't see

  • Each card's list item carries aria-setsize with the row's total and aria-posinset with its position, so "Stranger Things, 4 of 75" is announced even though only 18 cards exist in the DOM.
  • A card's accessible name includes what a sighted member sees at a glance: the title, and the progress if any ("Dark, 40 minutes left"). Art is decorative in that context, so its alt is empty.
  • The paging arrows are real buttons with labels like "Show more titles in Trending Now", and they move focus to the first new card.
  • Keyboard focus on a card opens the same expanded card as hover, with its Play, My List, and More info buttons reachable by Tab.

Previews that respect members

Auto-playing video is a problem for people with vestibular disorders and for screen reader users, whose speech competes with the trailer's sound. The previews chapter already starts them muted, lets members turn them off, and shows the still artwork when prefers-reduced-motion: reduce matches. One more rule: any preview that plays must have a visible pause control. WCAG's Pause, Stop, Hide criterion (2.2.2) requires one for moving content that starts by itself and lasts more than 5 seconds.

Captions and the player

  • Every title has captions; the profile remembers the language and whether they're on.
  • Members can style captions: size, font, colour, background, and edge. The custom renderer applies these everywhere.
  • Audio description tracks appear in the audio menu with a clear label.
  • All controls are buttons with names and states (aria-pressed for subtitles on), reachable by Tab, with shortcuts such as Space for play and pause and F for fullscreen.
  • Status changes ("Subtitles: English", "Skipped intro") are announced through one polite live region, not on every time update.

On TV, platform screen readers read what the app exposes through the platform's accessibility API. The focus engine must report every focus change to it, with the same names the web uses: row label, title, position in the row.

Check your understanding · Exercise 15

Which choices make the browse rows usable with a keyboard and screen reader? Select all that apply.

Observability: knowing playback is good

How would you know that a release made playback worse? Not from error logs alone: a player that stalls for 3 seconds every 10 minutes throws no errors, and members just get annoyed. Playback quality must be measured from real sessions, on real devices and networks, and broken down finely enough to find the cause. The real-user-monitoring lesson covers the general pipeline; here are the metrics specific to streaming.

MetricDefinitionWhy it matters
Startup time (play delay)From the Play press to the first frame shown.The wait members feel most; long waits lead to abandoned plays.
Rebuffer ratioSeconds stalled during playback, divided by total viewing time (played plus stalled). Services define it slightly differently.The main measure of smoothness. Our target: under 0.5 percent.
Rebuffers per hourCount of stalls per hour of viewing.Ten 0.5-second stalls often feel worse than one 5-second stall with the same ratio.
Video start failuresPlay attempts that ended in an error before the first frame, by error code.License refusals, unsupported devices, CDN errors.
Exits before startMembers who gave up before the first frame, without an error.Hidden startup problems that no error reports.
Average bitrate and switchesBits per second actually played, and quality changes per hour.Picture quality and stability.
Dropped framesFrom video.getVideoPlaybackQuality().The device can't decode the chosen quality; cap it.

Be careful what counts as a stall. When a member seeks, the player waits for data at the new position: that wait is expected and belongs to a separate seek latency metric. Only waits that interrupt normal playback count as rebuffering. Mixing the two would make a change that improves seeking look like it worsened smoothness.

ts
let watchedMs = 0, stalledMs = 0, stalls = 0, since = performance.now();
let current: PlayerState = "loading";

player.onStateChange((next) => {
  const now = performance.now();
  if (current === "playing") watchedMs += now - since;
  if (current === "rebuffering") stalledMs += now - since;
  if (next === "rebuffering" && current === "playing") stalls++; // not after "seeking"
  current = next;
  since = now;
});

function heartbeatPayload() {
  return { watchedMs, stalledMs, stalls, ratio: stalledMs / Math.max(1, watchedMs + stalledMs) };
}
Counting stalls from the player's state machine, not from raw video events, so seek waits are excluded.

Decision How much detail should every session send?

Every player event, every session

Send each state change, segment download, and switch.

Pros

  • Perfect detail for debugging.

Cons

  • Huge volume across millions of sessions; competes with video for bandwidth on weak links.

Summaries only

Each heartbeat sends counters since the last one: watched, stalled, stalls, average bitrate, switches, dropped frames.

Pros

  • Small and cheap; enough for dashboards and A/B tests.

Cons

  • Not enough to debug one strange session.

Summaries for all, traces for a sample

Every session sends summaries in its heartbeats.

A small random sample (say 1 percent), plus any session with an error, also uploads a detailed event trace at the end.

Pros

  • Reliable aggregate numbers and real traces to debug.

Cons

  • Two pipelines; traces must be sampled fairly or they mislead.

What we pick: Summaries for all, traces for a sample. Summaries answer "is it better or worse?", traces answer "why?". Tag both with device model, browser, app version, codec, DRM system, CDN server, and network type, because nearly every playback problem is specific to one of those.

Using the metrics to ship changes

Changes to the ABR rules, buffer goals, or preview delays should ship as A/B tests behind feature flags, with these metrics as guardrails: a new ABR rule that raises average bitrate by 5 percent but also raises the rebuffer ratio from 0.4 to 0.6 percent is a loss. Netflix is well known for testing nearly every product change this way. The ab-testing and feature-flags lessons cover the mechanics; the frontend's part is to send clean, comparable metrics with the experiment group attached.

Check your understanding · Exercise 16

After a release, the rebuffer ratio on one TV model rises from 0.3 to 1.2 percent while all other devices are flat. Which data helps find the cause? Select all that apply.

Running it in the interview

You can't cover all of this in 45 minutes, and you shouldn't try. The goal is to show you can find the hard parts, make clear decisions with numbers, and go deep on two or three of them. Here's one way to spend the time.

MinutesWhat to doWhat to draw or say
0 to 5Requirements: screens, devices, scale, DRM, profiles."TV with a remote changes everything, so I'll design for it from the start."
5 to 10The browse page wireframe and the architecture.Draw the wireframe, then the boxes: shell, browse, focus engine, preview controller, player, API client, CDNs, license server.
10 to 22Deep dive 1: rows of rows.Two-direction windowing with the 3,000-card numbers, lazy rows, artwork sizes and memory math.
22 to 32Deep dive 2: the player.Startup timeline, buffer goals for long titles, the EME license flow and its failures.
32 to 38Deep dive 3, the interviewer's pick: TV navigation or previews.The focus tree with row memory, or the preview state machine with abort.
38 to 45Resume, profiles, accessibility, metrics; questions.Heartbeats, profile-scoped cache keys, rebuffer ratio.

Say the numbers out loud. "3,000 cards would be 45,000 nodes, so I'll window both directions and render about 150" is much stronger than "I'll virtualize". "A 1280 by 720 image decodes to 3.7 MB, so on a TV we size art to the card" shows you know where the cost actually is. And name what you are not doing and why, like offline downloads being app-only.

Follow-up questions to expect

Think first

"How would you add offline downloads to the web app?"

Think first

"Netflix now streams some live events. What changes in the player?"

Think first

"How would ads fit into the player on the ad-supported plan?"

Check your understanding · Exercise 17

You have 10 minutes left and haven't discussed the player. Which moves make the best use of the time? Select all that apply.

Summary

  • The browse page is a grid of grids: window rows vertically and cards horizontally, load rows and row pages lazily, and keep each row's page and focus in a store that outlives the row.
  • Artwork is the main cost: size it to the card, use AVIF or WebP, decode ahead of page moves, and remember that decoded memory is width times height times 4 bytes.
  • Personalized artwork is a server choice over shared, cacheable images; the client shows it consistently and reports impressions.
  • Previews start in stages after the pointer or focus rests, prefetch with an AbortController, use one shared muted player, and respect the member's setting and reduced motion.
  • On TV, the app owns focus: a focus tree with per-row memory for rows, geometry only as a fallback, and key repeats that never queue animations.
  • The player streams CMAF segments through DASH or HLS with EME; the license request is on the startup path and carries the business rules, so run it in parallel and turn refusals into clear messages.
  • Long titles deserve long buffers and stable quality: switch up slowly, switch down fast, trim played video, and cap by screen and DRM level.
  • Resume works through heartbeats plus event saves with timestamps, a short-lived Continue Watching cache, and profile-scoped cache keys everywhere.
  • Measure playback from real sessions: startup time, rebuffer ratio, start failures, exits before start, bitrate, and dropped frames, tagged by device and release.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next