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.
| Question | Answer we assume | What 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. |
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.
- 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.
- 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.
- 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.
- 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.
- 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.
| Trait | What it forces | Chapter |
|---|---|---|
| Rows of rows | Two-direction virtualization, lazy row loading | The browse page |
| Artwork everywhere | Right-sized, modern image formats, decode budgets, personalized art | Artwork |
| Previews | A delay, cancellation, a preview state machine, prefetching manifests | Previews |
| TV and remote | Spatial navigation, focus memory per row, a 10-foot layout | TV and the remote |
| DRM | EME, license requests, per-browser quality limits | The player, DRM |
| Shared accounts | Profile-scoped caches, cross-device resume | Continue 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 TV. Without it, the browse page is a mouse and touch problem with a familiar set of tools: native scrolling, hover, and the browser's focus order. The TV adds spatial navigation, a separate rendering budget, a 10-foot layout, and memory limits that shape the virtualization and image decisions too.
DRM is a close second, but most of its work lives inside the browser and a player library. The frontend's job is mostly to pick the key system, request a license, and handle its failures.
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?
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.
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.
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 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.
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.
Decision Who decides which file each card loads?
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
artworkIdand 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?
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.
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.
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;
}
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 withNotAllowedErrorwhen 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.saveDatais true (Chromium only), or the measured bandwidth is very low, skip previews. - Stop when hidden. On
visibilitychangeto 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.
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.
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?
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?
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.
| Rung | Resolution | Video bitrate | One 4-second segment |
|---|---|---|---|
| 1 | 320 by 180 | 0.25 Mbps | about 125 KB |
| 2 | 640 by 360 | 0.75 Mbps | about 375 KB |
| 3 | 960 by 540 | 1.5 Mbps | about 750 KB |
| 4 | 1280 by 720 | 3 Mbps | about 1.5 MB |
| 5 | 1920 by 1080 | 5 Mbps | about 2.5 MB |
| 6 | 3840 by 2160 | 15 Mbps | about 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.
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 akeySystemConfigurationfor 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.
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 system | Key system string | Where you find it |
|---|---|---|
| Google Widevine | com.widevine.alpha | Chrome, Firefox, Edge, Android, Chromecast, many smart TVs |
| Microsoft PlayReady | com.microsoft.playready | Edge on Windows, Xbox, many smart TVs |
| Apple FairPlay | com.apple.fps | Safari 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
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
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.
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.
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?
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.
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
BroadcastChannelso 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?
No, as long as the kids profile never asks for those images. The HTTP cache is keyed by URL, and the kids profile's API responses only contain URLs for kids titles and kid-safe artwork. A cached image the page never references is never shown.
The risk is in the client's own data cache, where a key like browse without a profile id can serve the wrong response. That's why the profile id belongs in every data cache key, while shared, public image URLs need no change.
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.
| Piece | Responsibility | Why it's separate |
|---|---|---|
| App shell and router | Profile 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 page | Vertical row window, hero, row placeholders, lazy row loading. | Owns layout only; data comes from the cache, positions from the browse store. |
| Row and Card | Horizontal window, paging, card art and progress bar. | Rendered hundreds of times, so they are kept small and memoized. |
| Browse store | Row page index, TV focus memory, scroll position, keyed by row id. | Survives row unmounting and Back navigation. |
| Focus engine | Turns remote keys into focus moves on the focus tree; geometry fallback. | Works on indices, so it doesn't care which cards are rendered. |
| Preview controller | Intent timers, prefetch with abort, one shared preview video. | One owner for the only video decoder the browse page may use. |
| Player controller | State 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 cache | Requests to the backend-for-frontend, cache keyed by profile, retries. | One place for auth, profile scoping, and deduplication. |
| Telemetry | Impressions, 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
srcsetvariants. 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.
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.
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 };
| Data | Where it lives | Lifetime |
|---|---|---|
| Rows, titles, details | Query cache, keyed by profile id | Minutes; the Continue Watching row only seconds |
| Row pages, focus, scroll | Browse store plus history.state | The browse visit, restored on Back |
| Open details dialog | URL query parameter | Shareable, survives reload |
| Player state, buffer, ABR estimate | Player controller in memory | The playback session |
| Last bandwidth estimate, volume | localStorage per device | Weeks; device facts, not profile facts |
| Selected profile | Session cookie set by the server | The 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.
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"
}
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, withplayTarget(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/licensewith 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; returns204.PUT /api/my-list/t_81andDELETE /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.
{
"error": {
"code": "STREAM_LIMIT_REACHED",
"message": "Too many people are using this account right now.",
"retryable": false,
"details": { "activeStreams": 2, "planLimit": 2 }
}
}
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?
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
| Metric | Good threshold (75th percentile) | What threatens it here | Fix |
|---|---|---|---|
| LCP | 2.5 s or less | The 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 less | Hover 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 less | Rows 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?
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-setsizewith the row's total andaria-posinsetwith 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
altis 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-pressedfor 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.
| Metric | Definition | Why 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 ratio | Seconds 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 hour | Count 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 failures | Play attempts that ended in an error before the first frame, by error code. | License refusals, unsupported devices, CDN errors. |
| Exits before start | Members who gave up before the first frame, without an error. | Hidden startup problems that no error reports. |
| Average bitrate and switches | Bits per second actually played, and quality changes per hour. | Picture quality and stability. |
| Dropped frames | From 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.
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) };
}
Decision How much detail should every session send?
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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Requirements: screens, devices, scale, DRM, profiles. | "TV with a remote changes everything, so I'll design for it from the start." |
| 5 to 10 | The 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 22 | Deep dive 1: rows of rows. | Two-direction windowing with the 3,000-card numbers, lazy rows, artwork sizes and memory math. |
| 22 to 32 | Deep dive 2: the player. | Startup timeline, buffer goals for long titles, the EME license flow and its failures. |
| 32 to 38 | Deep 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 45 | Resume, 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?"
I'd first say that Netflix doesn't offer downloads on the web; they're in the mobile apps. The reason is DRM: studios require an offline license with an expiry and a limit on devices, and browsers don't offer a persistent protected download store that meets those rules for this kind of content.
In the native apps, the client downloads encrypted segments at a chosen quality, requests a persistent offline license with an expiry, tracks downloads in a local database, and renews or deletes the license when the device comes online. If pushed on the web, I'd describe EME's persistent-license session type, note that support is limited across browsers, and recommend against it.
Think first
"Netflix now streams some live events. What changes in the player?"
The manifest keeps changing as new segments appear, so the player reloads it on a timer or uses a low-latency mode. Buffers are short, a few seconds to tens of seconds, because you can't buffer the future, so the ABR rule must react faster and startup quality matters more.
Huge numbers of members join at the same moment, so the client should add random jitter to manifest reloads and retries to avoid a thundering herd on the CDN. Resume becomes "jump to live" instead of a saved position, and the seek bar shows a live window instead of a fixed duration.
Think first
"How would ads fit into the player on the ad-supported plan?"
The playback metadata lists ad breaks by time. The simplest client approach is to stitch ads into the stream on the server, so the player plays one continuous manifest and only draws an ad overlay and a countdown. Seeking past a break may require playing it first, and the progress bar must show content time, not stream time.
Client-side insertion, switching between a content source and an ad source, gives more targeting control but adds a second startup and DRM edge cases at each break. I'd lean on server-side stitching for TVs, where every extra startup hurts the most.
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.
References
- MDN: Media Source Extensions API MediaSource, SourceBuffer, and how segments reach the video element.
- MDN: Encrypted Media Extensions API Key systems, MediaKeys, sessions, and license messages.
- W3C: Encrypted Media Extensions The specification behind requestMediaKeySystemAccess and MediaKeySession.
- Netflix Tech Blog: Artwork Personalization at Netflix How Netflix picks artwork per member with contextual bandits.
- Netflix Tech Blog: Per-Title Encode Optimization Why each title gets its own bitrate ladder.
- Netflix Tech Blog: Crafting a high-performance TV user interface using React React on Netflix's Gibbon rendering layer for TVs.
- A Netflix Web Performance Case Study Server-rendering the logged-out pages with little client JavaScript, and prefetching the sign-up flow.
- Netflix Tech Blog: Migrating Netflix to GraphQL Safely How the mobile apps moved from Falcor to federated GraphQL.
- Netflix Help Center: How to turn preview autoplay on or off The per-profile setting that the preview controller checks first.
- web.dev: Web Vitals LCP, INP, and CLS with their thresholds.
- WAI-ARIA APG: Developing a keyboard interface Roving tabindex and arrow-key movement inside composite widgets.
Concepts behind it
- List virtualizationRendering only the rows the user can see in a long list, so a list of 100,000 items costs about as much as a list of 30.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Prefetching and preloading dataStarting the fetch for the data and code of the next screen before the user asks for it, on hover, on sight, or on a guess, so the click feels instant without wasting the user's data plan.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Real-user monitoring (RUM)Measuring speed on your real users' devices and networks: collecting Web Vitals and Performance API timings, sending them safely, and reading them as percentiles by device, network, country, and page to find what an average hides.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Server-side renderingThe server builds the full HTML for each request, so users see content before any JavaScript runs, then the browser hydrates it: faster first paint and better SEO, paid for with server work, a later first byte, and a second render in the browser.