Problem RestatementProblem
Netflix asked: the homepage shows many rows ("Continue Watching", "Trending Now", "Because you watched X", ...), each a horizontal list of titles. Design how it renders:
- Viewport-based fetching: only load the rows (and titles within rows) that are visible or about to become visible, as the user scrolls down or sideways, especially on slow TVs.
- Deduplication: the same show shouldn't appear in several rows on the screen at the same time (it wastes space and looks broken), even though different row algorithms may independently pick it.
RequirementsRequirements
- The first screen appears fast (under ~1 s), even on low-end TVs.
- Load more rows on vertical scroll, and more titles on horizontal scroll.
- No duplicate titles across rows within a homepage session (or at least within the visible area).
- Personalized rows. Consistent while the user scrolls (rows don't reshuffle).
ArchitectureArchitecture
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
TV["Client - TV / phone / web"] -->|"page 1: rows 1-4"| HP["Homepage service (BFF)"]
HP --> ROWS["Row generators - personalized candidates"]
HP --> SESS[("Page session - shown title set, row plan")]
HP --> CACHE[("Candidate cache per user")]
TV -->|"next rows / more titles in row"| HP- A Homepage service (backend-for-frontend) assembles pages for a page session (one visit to the homepage).
- Row generators (ranking services) produce candidate lists per row type, and are cached per user for a short time.
- A page session stores the row plan (order of rows) and the set of titles already shown.
Viewport Fetching
- First request: return the first ~4 rows × the first ~10 titles each (what fits on screen plus a little extra), plus a
session_idand cursors. - Vertical scroll: when the user nears the bottom, request
GET /home/rows?session=...&after_row=4&count=3. - Horizontal scroll:
GET /home/row/{row_id}/items?session=...&cursor=...to extend one row. - Images are lazy-loaded (only visible artwork is fetched), with low-res placeholders first.
- Payloads are small, and TVs get pre-sized images.
Deep Dive — The same title in three different rowsDeep dive
"Continue Watching", "Trending Now" and "Because you watched X" are ranked independently, so the same popular title can legitimately win a place in all three. On screen it looks broken.
Filter duplicates on the client
Let each row be built independently and have the browser drop titles it has already rendered.
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
S["Server returns 3 rows, each with 20 titles"] --> C["Client drops duplicates"]
C --> ROW1["Row 1: 20 titles"]
C --> ROW2["Row 2: 14 left"]
C --> ROW3["Row 3: 6 left - the row looks broken"]
ROW3 --> NOFILL["Client cannot fetch replacements - it has no ranking"]Removing an item is not the same as replacing it. The client has no access to the next-best candidate, so rows get shorter as you scroll and the worst-affected rows are the ones furthest down — exactly the ones personalisation worked hardest on.
Deduplicate on the server, per request
Build the rows server-side in one pass, skipping titles already placed, and take the next candidate so every row stays full.
Correct within one response, because the server holds the full ranked candidate list for each row and can substitute. It breaks across requests: the homepage loads more rows as the user scrolls, and a second request has no memory of the first, so row 9 happily repeats what row 2 already showed.
A per-session shown-set, applied in priority order
%%{init: {"look":"handDrawn","handDrawnSeed":7,"theme":"base","fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","themeVariables":{"fontFamily":"Virgil, \"Segoe Print\", \"Comic Sans MS\", cursive","fontSize":"16px","primaryColor":"#fff4e6","primaryBorderColor":"#1e1e1e","primaryTextColor":"#1e1e1e","secondaryColor":"#e7f5ff","tertiaryColor":"#ebfbee","lineColor":"#1e1e1e","textColor":"#1e1e1e","mainBkg":"#fff4e6","nodeBorder":"#1e1e1e","clusterBkg":"#f8f9fa","edgeLabelBackground":"#ffffff","classText":"#1e1e1e"}}}%%
flowchart LR
REQ["Page request"] --> SESS[("Session shown-set")]
SESS --> ORD["Rows in priority order - Continue Watching owns its titles"]
ORD --> PICK["For each row, walk ranked candidates, skip anything in the set"]
PICK --> FILL["Take the next best until the row is full"]
FILL --> ADD["Add the chosen titles to the shown-set"]
ADD --> SESS
SESS --> LATER["Later pages and lazily loaded rows use the same set"]- Priority order matters. Rows are processed in a fixed order so the row with the strongest claim to a title keeps it — "Continue Watching" should never lose a title to "Trending Now".
- Persist the set for the session, so scrolling and lazy-loading later rows cannot reintroduce a title from the top of the page.
- Substitute rather than remove. Because the server still holds the ranked candidates, a skipped title is replaced by the next best, and every row renders at full length.
Storing the placements in the session, not just the ids, also makes the page stable: a re-render or a back-navigation reproduces the same layout instead of reshuffling, which is what makes the homepage feel solid rather than alive in a distracting way. The client can keep a final safety check, but as a backstop for bugs — never as the mechanism.
Performance and CachingScale
- Precompute or cache candidate lists per user (they don't need to be real-time), so page assembly is fast.
- The session state is small (the plan + a set of title IDs) and stored in a fast KV store with a TTL (e.g., 1 hour).
- On TVs, keep the client light: the server returns ready-to-render rows, and the client just draws and prefetches the next set.
Wrap-UpWrap-up
A homepage BFF creates a page session with a row plan and returns only the rows and titles needed for the first viewport, with cursors for vertical row paging and horizontal in-row paging, while images load lazily. Deduplication happens on the server per session: rows are filled in priority order from cached, personalized candidate lists, skipping titles already shown and recording new ones, so later pages stay duplicate-free and the page stays stable while the user scrolls.