cf.completefrontend
My progress
SYSTEM DESIGN · #91

Design an e-commerce site like Amazon

The storefront of a huge online marketplace: home, search results with filters, product pages, cart, and checkout. The hard parts are picking a rendering strategy per page type, keeping filters in the URL, a product page that paints fast and handles variants, prices and stock that change under the shopper, a checkout that never charges twice, and many sellers competing for the same product.

The question

Design the frontend of a large online marketplace like Amazon. Shoppers land on a home page, search for products, narrow the results with filters, open a product page, pick a size or color, add it to the cart, and check out. Many different sellers can offer the same product.

Walk me through how each page is rendered and cached, how search and filters work, what the product page does to load fast, how you keep prices and stock correct, and how checkout takes payment safely and exactly once.

Real-life examples: Amazon, Walmart.com, eBay, Flipkart, Etsy, Zalando

Requirements: what to ask before drawing anything

"Design Amazon" is one of the broadest questions you can get in a frontend interview. A real store has hundreds of teams and thousands of pages, and you have 60 minutes.

So the first job is not to design. It's to cut the problem down to the parts that show frontend judgment, and to say out loud what you leave out. Spend the first 5 to 7 minutes on questions.

A good way to find the questions: walk the shopper's path once, out loud. They land on a home page, type a search, get a page of results, tick a few filters, open a product, pick a size, add it to the cart, and pay. Every step on that path is a page type, and every page type has its own traffic, its own freshness needs, and its own risk. Your questions should find out which of those numbers matter.

QuestionAnswer we assumeWhat it changes
How many products, and how many visitors?About 50 million products. 20,000 page views per second on a normal day, 100,000 at a big sale peak.We can't render every page per request from scratch. Caching at the CDN (content delivery network) is required, not optional.
Where do visitors come from?About half arrive from search engines and ads straight onto a product or search page, not the home page.Product and search pages must be server-rendered, fast on first load, and readable by search engine crawlers.
What devices and networks?Most traffic is on phones, many of them mid-range Android phones on slow 4G.Small JavaScript budgets, responsive images, and Core Web Vitals measured on phones.
Is the site personalized?Yes: recommendations, recently viewed, delivery dates for the shopper's address, and Prime-style badges.Personal parts can't sit in a shared CDN cache. We need to split shared and personal content.
Who sells?The store itself plus thousands of third-party sellers. One product can have 20 offers.Product page shows a chosen default offer (the buy box) plus a list of other sellers.
How fresh must prices and stock be?The price charged must be the price shown at checkout. Stock can be a few minutes stale on browse pages.Different freshness per page: loose on listing, tight at add-to-cart and checkout.
Which countries?Several, each with its own currency, language, tax rules, and address format.Locale in the URL or domain, formatted prices, and per-market caches.
How do people pay?Cards through a payment provider, wallets (Apple Pay, Google Pay), and saved cards.Card fields in the provider's iframe so we stay out of most PCI scope; 3-D Secure checks.

Functional requirements

  • Home page: shared promotions plus personal rows (recently viewed, recommended for you).
  • Search and category listing: results for a query or a category, with faceted filters (brand, price range, rating, size, delivery speed), sort, result counts, and pagination.
  • Product detail page (PDP): images with zoom, title, price, variants such as size and color, availability and delivery date, the buy box, other sellers, reviews, and recommendations.
  • Cart: add, change quantity, remove, save for later. We only summarize it here: the shopping-cart guide covers it in depth.
  • Checkout: address, delivery option, payment, review, and place order, exactly once.
  • Accounts: sign in, saved addresses and cards, order history. We keep these thin in this guide.

Non-functional requirements

  • Speed on phones: Core Web Vitals good at the 75th percentile of real visits: LCP (Largest Contentful Paint, when the main image or text block appears) at most 2.5 s, INP (Interaction to Next Paint, how fast the page reacts to a tap) at most 200 ms, CLS (Cumulative Layout Shift, how much things jump) at most 0.1.
  • Search engine friendly: every product and category page has real HTML, a stable URL, a title, a canonical link, and structured data.
  • Correct money: the shopper is never charged a different price than the one confirmed on the review step, and never charged twice.
  • Survives peaks: a sale that brings 5 times normal traffic must not take the site down. Most pages must be served from cache.
  • Accessible: keyboard and screen reader friendly, WCAG 2.2 AA, including filters, galleries, and the checkout form.
  • Safe: payment pages locked down against injected scripts; bots that scrape prices or buy up limited stock slowed down.
  • Measurable: every change ships behind an A/B test with guardrail metrics, because small conversion changes are worth a lot of money.

Out of scope, and why

  • The search ranking algorithm: a back-end and machine learning problem. We consume a ranked list.
  • Seller tools (listing products, managing inventory): a separate app with different users.
  • Order tracking, returns, customer service: real, but they don't add new frontend ideas beyond what we cover.
  • Native mobile apps: we design the web. Most decisions about APIs carry over.

Check your understanding · Exercise 1

Which answers to scope questions change the frontend design the most (select all that apply)? Select all that apply.

What makes an online store hard

A store looks simple: lists of things with prices and a button. Five properties make it hard, and every later chapter comes back to one of them.

  1. Many page types, each with different needs. The home page is personal. A search page is shared but has millions of filter combinations. A product page is mostly shared but has a personal delivery date and a stock count that changes. Checkout is fully personal and must be exact. One rendering strategy for the whole site is wrong for most of it.
  2. A huge, long-tail catalog. With 50 million products, a few thousand best sellers get most of the views, and millions of products get a visit a day or less. Popular pages stay warm in the CDN cache. Tail pages are almost always a cache miss, so the origin server still renders a lot, and you can't build all pages ahead of time.
  3. Money on every screen. Price, discount, currency, tax, and stock appear on every page and change often. A wrong price is a legal and trust problem, not a cosmetic one. The frontend shows numbers; the server decides them, and the last check happens before the order is placed.
  4. Shared and personal content on the same page. A product page is 90 percent the same for everyone (title, images, description) and 10 percent personal (your delivery date, "you bought this before", your cart count). Mixing them in one HTML response kills caching. Splitting them is a core design move.
  5. Conversion is the product. Small changes in speed or layout move revenue, so everything is A/B tested, marketing wants third-party tags, and bots scrape prices or try to buy up limited stock. Each of these fights the others: tags slow the page, experiments cause layout shift, bot checks add friction.

The table puts numbers on the first property. Shares are assumptions for a big general store, close enough to reason with. Notice how no two rows want the same thing.

Page typeShare of viewsNeeds SEO?Personal partsHow fresh
Homeabout 10%a little (brand searches)most of the pageminutes for promos, live for personal rows
Search and category listingabout 35%yes for categories, less for free-text searchsmall (delivery badges, sponsored slots)minutes for stock and price badges
Product pageabout 45%yes, the mostsmall but important (delivery date, buy box for your address)price and stock within a minute or two
Cartabout 5%noall of itlive
Checkoutabout 5%no (and must not be indexed)all of itexact, checked by the server
The shopper's path through five page types: Home with 10 percent of views, Search with 35, Product with 45, Cart with 5, and Checkout with 5. SEO matters some for home, yes for search, most for product, and no for cart and checkout. Cacheable: home per segment, search yes, product only its shell, cart and checkout no. Personal content: most of home, a little of search and product, all of cart and checkout.Home10% viewsSearch35% viewsProduct45% viewsCart5% viewsCheckout5% viewsSEOsomeyesmostnonocacheablesegmentyesshellnonopersonalmostlittlelittleallall
No two columns ask for the same thing. Search and product pages carry most views and most SEO value and can be shared, while cart and checkout are fully personal, which is why one rendering strategy for the whole site fails.

Think first

A teammate says: "Let's make the whole site a single-page app with client-side rendering. One codebase, simple." What breaks first, and for whom?

Check your understanding · Exercise 2

Why can't the store simply build every product page ahead of time as static HTML at deploy (select all that apply)? Select all that apply.

Rendering strategy, one page type at a time

The published lesson "Choosing a rendering strategy" gives the general rule: decide per route, and inside a page split a cached shell from personal or fast-changing holes. This chapter applies it to a store, with the numbers and headers you'd say in the interview. If the terms SSR, ISR, or streaming are new, the lessons "Server-side rendering", "Static generation and incremental regeneration", and "Streaming server rendering" cover them from zero.

Decision How should the store render its pages?

One single-page app, rendered in the browser (CSR)

Every URL returns the same small HTML file and a JavaScript bundle.

The bundle routes, calls the APIs, and renders.

Pros

  • One simple deploy: static files on a CDN, no render servers.
  • Fast page-to-page navigation once loaded.

Cons

  • Slow first paint for the half of visitors who land from search: HTML, then JS, then API, then render.
  • Crawlers and link previews get an empty page or a late, budget-limited render.
  • Every visitor pays for a large bundle on a slow phone.

Server-render every page per request (SSR, no cache)

A Node server renders full HTML for each request, including personal parts.

The browser hydrates it to make it interactive.

Pros

  • Complete HTML for crawlers and a fast first paint.
  • Personal content is in the first response.

Cons

  • At 100,000 views per second at peak, the render fleet is huge and every request waits for the slowest back-end call.
  • Personal HTML can't be shared through a CDN, so the cache does almost nothing.

Prebuild everything (SSG) and rebuild on change

Generate HTML for every page at deploy, upload to the CDN.

Pros

  • Fastest possible delivery for the pages that exist.

Cons

  • 50 million products make a full build take hours.
  • Prices and stock change between builds.
  • Personal parts can't be in a static page.

Mix per page type: cached shared shell plus personal or fresh holes

Shared, crawlable pages are server-rendered once and cached at the CDN with a short lifetime and background revalidation (ISR or cached SSR).

Fast-changing and personal parts are streamed holes or small client fetches.

Cart and checkout are rendered privately and never shared-cached.

Pros

  • Most views are served from the CDN, so peaks hit the cache, not the servers.
  • Complete HTML for crawlers; fast LCP.
  • Each part gets exactly the freshness it needs.

Cons

  • More moving parts: cache keys, fragment APIs, and rules about what may be cached where.
  • Mistakes are subtle: one personal value in a shared page leaks data or breaks caching.

What we pick: Mix per page type. It's the only option that is fast for search landings, survives sale peaks, and keeps money values correct. The extra moving parts are real, so we make them explicit: a short list of cache rules per page type, and a strict rule that shared HTML never contains a personal value.

The decision for each page type

PageRenderingShared cachePersonal and fresh parts
HomeStreaming SSR of a shell cached per segment (country, language, signed in or not)s-maxage=60 plus stale-while-revalidatePersonal rows (recently viewed, for you) fetched by the client below the fold, or streamed if above it
Category listingISR or cached SSR, one cache entry per normalized URLs-maxage=300Sponsored slots and delivery badges as small fragments
Free-text searchSSR, cached briefly; most queries are rare, so hit rates are lows-maxage=60 for popular queriesSame as listing
Product pageISR or cached SSR shell; streaming for slow sectionss-maxage=300 for the shellPrice, stock, buy box, delivery date: a fresh fragment with its own short lifetime
CartPrivate SSR or CSR from a static shellnone: private, no-storeAll of it
CheckoutPrivate SSR with little JavaScript, forms that work without itnone: private, no-storeAll of it; the server re-prices before placing the order
Page type
Shared part fresh within

Cached SSR or ISR at the CDN

Product page. 90 percent of the page is shared and 10 percent is personal. Strategy: Cached SSR or ISR at the CDN. Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600. About one render per URL per 5 min per CDN location, whatever the traffic. Plus 9,000 small personal requests per second.Product pageshared, cachedCache-Control: public, max-age=0,s-maxage=300, stale-while-revalidate=600Cached SSR or ISR at the CDNfresh within: 5 minviews: 9,000 per second
Personal part (10%): a hole filled by a private request, or streamed if it is in the first view.

About one render per URL per 5 min per CDN location, whatever the traffic. Plus 9,000 small personal requests per second.

Personalization versus caching

The trap that sinks most store designs is the cookie. Signed-in shoppers send a session cookie with every request. If the CDN includes the cookie in the cache key, or the server answers with Vary: Cookie, every shopper gets a private copy and the hit rate drops to near zero. If the server instead renders the shopper's name into a page the CDN then shares, the next shopper sees it, and both mistakes are easy to make.

The fix has three steps. First, the CDN strips cookies from the cache key for shared pages. Second, an edge function reads the cookie and turns it into a small segment: country, language, currency, signed in or not, member or not.

Within one market that's about 2 x 2 x 2 combinations (two languages, signed in or not, member or not), so 8 cache entries per URL instead of one per shopper. The segment travels as a request header that is part of the cache key. Third, anything finer than a segment (your name, your cart count, your delivery date) is a hole, filled by a separate request that is never shared-cached.

http
# Product page shell (same for everyone in the segment)
Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=600

# Offer fragment: price, stock, buy box
Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=30

# Delivery date for this shopper's address
Cache-Control: private, no-store

# Cart and checkout pages
Cache-Control: private, no-store
Cache rules per response. The shell is shared; the fragments carry their own lifetimes.

max-age=0 tells the browser to revalidate every time, while s-maxage lets the CDN keep its copy. stale-while-revalidate lets the CDN serve the old copy for a while and refresh it in the background, so a popular page never makes a shopper wait for a render. Support differs between CDNs, so check yours; the "CDN caching" lesson covers the details.

Where the edge and streaming help

Rendering at the edge only helps when the data is at the edge too. Product data lives in a few regions, so we render near the data and use the edge for cheap, fast decisions: which market and language, which A/B variant, which segment header, and bot checks.

Streaming helps the product page and home page. The server sends the head (with a preload for the hero image) and the top of the page in the first bytes, then streams reviews and recommendations as their data arrives. TTFB (time to first byte) stays low even when the recommendations service is slow.

Check your understanding · Exercise 3

A signed-in shopper opens a product page. Which designs keep the page shared-cacheable and still correct (select all that apply)? Select all that apply.

Search and listing: filters that live in the URL

The listing page is where shoppers narrow 40,000 "running shoes" down to the 12 they will look at. It is also a page search engines crawl heavily and a page shoppers return to with Back all the time, and both facts point the same way: the URL is the state. This chapter shows what that means in practice. The search box itself is the autocomplete guide, and the results grid shares ideas with the data-table and pinterest guides, so we don't repeat those.

The naive version and where it breaks

The naive version keeps selected filters in component state and fetches results with fetch when a checkbox changes. Picture a shopper who ticks "Nike", "Size 10", and "Under $100", opens a shoe, and presses Back: the page reloads with no filters, because the state died with the component. They can't send the filtered list to a friend either, and Google only ever sees the unfiltered category. Three failures from one decision.

With the URL as the single source of truth, a filter click changes the URL, and the page renders from the URL. Back, refresh, share, open in a new tab, and crawl all work for free, because each of them just loads a URL. The "URL as state" lesson covers the general pattern; here are the store-specific parts.

ts
type Filters = { q?: string; category?: string; facets: Record<string, string[]>; price?: [number, number]; sort: string; page: number };

export function toSearchUrl(f: Filters): string {
  const p = new URLSearchParams();
  if (f.q) p.set("q", f.q.trim().toLowerCase());
  for (const key of Object.keys(f.facets).sort()) {
    const values = [...f.facets[key]].sort();
    if (values.length) p.set(key, values.join(","));
  }
  if (f.price) p.set("price", `${f.price[0]}-${f.price[1]}`);
  if (f.sort !== "relevance") p.set("sort", f.sort); // drop defaults
  if (f.page > 1) p.set("page", String(f.page));
  const base = f.category ? `/c/${f.category}` : "/s";
  const qs = p.toString();
  return qs ? `${base}?${qs}` : base;
}
One function turns filter state into a canonical URL, so equal filters always give an equal URL and an equal cache key.

Normalizing matters more than it looks. brand=nike,asics and brand=asics,nike are the same results. If the URL isn't normalized, the CDN stores them twice and search engines see duplicate pages. Sorting keys and values, lowercasing, and dropping defaults (sort=relevance, page=1) can double the cache hit rate on popular categories.

Result counts per filter value

Each filter value shows how many results you'd get: "Nike (1,204)". The subtle part is how counts work inside a group. If the shopper has ticked Nike, should Asics show 0 (because nothing is both Nike and Asics) or 830 (the Asics shoes that ticking it would add)?

Shoppers expect 830, because values in one group are joined with OR and groups are joined with AND. So the server computes each group's counts with every filter applied except that group's own. Search engines like Elasticsearch and Algolia support this pattern, often called disjunctive faceting. The frontend's job is to ask for it and to show it right: a value that would give zero results is disabled with its count, not hidden, so the list doesn't jump.

Brand
Size
Sort
Counts

40 results. History has 1 entry.

Facet counts. Brands: asics 12, brooks 4, hoka 8, nike 16. Sizes: 9 14, 10 13, 11 13. 40 results. Canonical URL /c/shoes.brandasics 12brooks 4hoka 8nike 16size9: 1410: 1311: 13/c/shoesno filters40 resultsnike #1, size 9, $60nike #2, size 10, $73asics #3, size 11, $86
Each group's counts ignore that group's own selection, so after ticking one brand the others still show what adding them would give. Clicks in any order give the same URL: /c/shoes

Clicked order: /c/shoes

Canonical: /c/shoes

What happens when a filter changes

  • On desktop, apply at once. Each checkbox change builds the new URL with the function above and calls the router's push (which uses history.pushState), so Back undoes the last filter. Results load from the search API for the new URL.
  • Cancel stale requests. A shopper ticking three boxes quickly starts three requests. Each one gets an AbortController; a new filter change aborts the previous request, so an old, slower response can never overwrite a newer one.
  • Keep the old results on screen while loading, dimmed, with the counts still visible. Clearing the grid makes the page jump and costs CLS.
  • Price ranges are typed, so debounce them (about 400 ms) and use replaceState while typing, so Back doesn't step through every keystroke.
  • On phones, filters live in a full-screen drawer with an Apply button. Applying each tick at once on a slow network would fire a request per tap. The drawer shows a live result count on the button ("Show 214 results") from a cheap count-only request.
  • Announce changes. After results update, a polite live region says "214 results", so screen reader users know the filter worked.
A filter change, between the Shopper, the FacetPanel, the URL and router, and the Search API. The shopper ticks Nike; the panel pushes brand=nike to the URL; the router sends search request 1. The shopper quickly ticks size 10; the panel pushes size=10; the router aborts request 1 and sends request 2. The API answers with 214 items and facet counts; the router renders the grid and counts; the panel's live region says 214 results.ShopperFacetPanelURL + routerSearch APItick Nikepush brand=nikeGET search #1tick size 10push size=10abort #1, GET #2214 items + countsrender gridsays 214 results
The URL changes on every click, so Back undoes each filter. Request 1 is cancelled with its AbortController, so its late answer can never overwrite the newer results.

Pagination or infinite scroll

Decision How should shoppers move past the first page of results?

Numbered pages with real links

Each page is its own URL, such as ?page=3, rendered on the server.

Links are real a elements that work without JavaScript.

Pros

  • Crawlers can follow links to every page and find tail products.
  • Back and shared links return to the exact page.
  • The footer stays reachable.

Cons

  • A full page change for each step, which feels slow on phones.
  • Shoppers browse less deeply.

Infinite scroll

An IntersectionObserver near the bottom loads the next batch and appends it.

Pros

  • Smooth on phones; good for visual browsing such as fashion.

Cons

  • Crawlers don't scroll, so products past the first batch may never be found from this page.
  • Back after opening a product often lands at the top, losing the shopper's place.
  • The footer is unreachable and the DOM grows without limit.

Load-more button that also updates the URL

A button appends the next page and calls replaceState to ?page=2.

The server can render ?page=2 directly as pages 1 and 2, and plain page links exist for crawlers.

Pros

  • Phone-friendly, and Back restores how far you were.
  • Crawlable through the same paged URLs.

Cons

  • Two code paths to keep consistent: appended and server-rendered.

What we pick: Numbered pages on desktop, a load-more button on phones, both backed by the same ?page=N URLs that the server can render. Each paged URL is its own canonical, not pointed at page 1, so crawlers keep the products on later pages. We cap deep paging (for example at 20 pages) and nudge shoppers to filter instead: nobody shops page 300, but scrapers do. The "Pagination" lesson covers offset versus cursor; ranked search results use offsets here because they're shallow and need page numbers.

Not every filter combination should be indexed

Ten filter groups with a handful of values each make millions of URLs per category. If crawlers follow them all, they spend their time on near-duplicates and find new products later. So split filter URLs into two kinds.

A few combinations with real search demand ("nike running shoes", "waterproof hiking boots") become indexable landing pages, ideally with clean, fixed-order paths such as /c/running-shoes/nike, their own title, and plain links to them. The rest (sort orders, price ranges, three filters deep) are kept out of the crawl, for example with robots.txt rules on those parameters.

Google's guidance on faceted navigation says rel="canonical" and rel="nofollow" are generally less effective in the long term than blocking with robots.txt. For filter URLs that stay crawlable, it asks for & as the parameter separator (commas are hard for crawlers to read as separators), a fixed filter order in paths, and a real 404 for a filter combination with no results.

Think first

The URL holds the filters, yet shoppers still complain that Back from a product page loses their place in a long list. What else do you need?

Check your understanding · Exercise 4

A shopper has ticked Nike in the Brand group and Size 10 in the Size group. Which statements about the facet counts are right (select all that apply)? Select all that apply.

The product page: fast hero, variants, and lazy everything else

The product page gets the most views and the most search landings, and it's where the buying decision happens. Its design goal is easy to state: the main photo, title, price, and Add to cart button must be visible and usable within about 2.5 seconds on a mid-range phone, and nothing should jump. Everything else on the page (reviews, recommendations, Q and A, comparison tables) can arrive later. The wireframe names the parts.

Product page wireframe. A header with the search box and a private cart count. On the left, the large hero photo, which is the LCP element, with four thumbnails under it. On the right, the title Trail Runner 4, the rating 4.4 stars from 2,318 ratings, the price 89 dollars 99, color swatches blue, black, and red with blue selected, sizes 9, 10, 11, and 12 with 10 selected and 11 out of stock, and the buy box with Add to cart, sold by Acme, and 12 offers from 79 dollars. Below the fold, reviews load on scroll and recommendations sit in boxes with a reserved height.shop.example.com/p/tr4?color=blue&size=10header: search box, cart count (private)hero photo: LCP1234Trail Runner 44.4 stars, 2,318 ratings$89.99blueblackred9101112Add to cart, sold by Acme12 offers from $79reviews: load on scrollrecommendations: fixed height
Everything above the dashed boxes should paint within the LCP budget. The shell, photo, and variants come in the cached HTML; the price, buy box, and stock come from the fresh offer fragment; the dashed boxes load only as the shopper scrolls near them.

The hero image is the LCP element

On almost every product page, the LCP element is the main product photo. So the rules for that one image are strict, and they are the opposite of the rules for every other image on the page:

  • It's a plain img in the server HTML, not created by a carousel script after hydration. The browser's preload scanner finds it in the first bytes.
  • It has fetchpriority="high" and no loading="lazy". Lazy loading the LCP image is one of the most common LCP mistakes.
  • It has srcset and sizes, so a phone downloads a 600-pixel-wide file, not a 2,000-pixel one. An image CDN serves AVIF or WebP based on the Accept header.
  • It has width and height (or a CSS aspect-ratio), so the space is reserved and nothing shifts when it loads.
  • Thumbnails and the other gallery photos are lazy. Only the first photo competes for bandwidth early.

The "Images and media" lesson explains each attribute; the image-carousel guide covers the gallery control itself. The point to make in the interview is that the hero photo and the rest of the gallery are two different loading problems.

Gallery and zoom

Zoom needs a large image, often 2,000 pixels or more and several hundred KB. Loading it up front would steal bandwidth from the hero. Load it on intent instead: on pointerenter over the photo on desktop (the shopper usually hovers for a moment before the zoom pane matters), and when the full-screen viewer opens on a phone.

Desktop hover zoom shows a lens and a side pane. Phones get a full-screen viewer with pinch zoom, which must be a proper dialog with a close button and focus management, not only a gesture. Product videos load a poster image first and fetch the video only on play.

Variants: size, color, and the one the shopper actually buys

A shoe comes in 8 colors and 12 sizes. That's one product family with up to 96 SKUs (stock keeping units: one buyable item, such as "blue, size 10"). Each SKU can have its own price, its own stock, and its own seller offers, and each color has its own photos. The page must answer three questions as the shopper clicks: which combinations exist, which are in stock, and what does this exact one cost.

Decision How does the page get variant data when the shopper changes size or color?

Full page load per variant

Each variant is its own URL; clicking a color navigates to it.

Pros

  • Simple; every variant URL is crawlable and shareable.

Cons

  • Each click costs a full page load: slow and jumpy on a phone.
  • Scroll position and open panels are lost.

Fetch the variant on each click

Clicking a size calls the API for that SKU's price and stock.

Pros

  • Always fresh.

Cons

  • Every click waits for the network; on slow 4G a size change takes a second.
  • Can't show which sizes are out of stock before the click.

Ship the variant matrix with the page, then refresh the selected offer

The server HTML includes a compact matrix: for each SKU, its dimensions, list price, and a stock level bucket.

Clicking switches instantly from the matrix and updates the URL with replaceState.

A small request then fetches the fresh offer (exact price, stock, delivery) for the selected SKU.

Pros

  • Instant switching and disabled out-of-stock sizes before any click.
  • Freshness where it counts, for the one SKU being bought.

Cons

  • The matrix adds bytes: 96 SKUs at about 80 bytes each is about 8 KB before compression.
  • The matrix can be a few minutes stale, so the fresh offer may disagree with it.

What we pick: Ship the matrix and refresh the selected offer. It's how a shopper expects it to feel: instant, with out-of-stock sizes visible up front. When the fresh offer disagrees with the matrix (the matrix said "in stock", the offer says "only 1 left" or "out of stock"), the fresh offer wins and the page says so in words. Variant URLs still exist (?color=blue&size=10, or a separate path per child SKU) so the selected variant can be shared and indexed, with a canonical link to the family page if the variants are near duplicates.

The variant matrix for three colors and four sizes. Blue: size 9 in stock, size 10 low, size 11 out, size 12 in stock. Black: sizes 9, 10, and 11 in stock, no size 12. Red: size 9 out, size 10 low, no sizes 11 or 12. Blue size 10 is selected. An arrow from it leads to the fresh offer for that SKU: only 3 left at 89 dollars 99, sold by Acme, delivery Monday.9101112blueblackredinlowoutininininnoneoutlownonenonematrix in the HTML, minutes oldoffer TR4-BLU-10only 3 left, $89.99sold by Acmedelivery Mondayin: greenlow: hatchedout: red, focusable
The matrix comes with the HTML, so switching is instant and out-of-stock sizes are visible before any click. The fresh offer for the one selected SKU arrives a moment later and wins if they disagree.

Two details separate good answers. First, don't hide unavailable combinations; show them struck through or marked "out of stock" and keep them focusable, so the shopper learns that size 11 exists in another color and screen reader users hear why it can't be picked. Second, the price shown before selection is a range ("$80 to $120") or a "from" price, and it turns into one exact price only once every dimension is chosen. Add to cart stays disabled with a clear message ("Choose a size") until then.

Reviews and recommendations, below the fold

Reviews matter for buying and for search, but many shoppers never scroll that far. So the server renders the rating summary (average stars, count, and the star histogram) with the page, because it's small, matters for the decision, and feeds structured data.

The review list itself loads when its section comes near the viewport: an IntersectionObserver with a rootMargin of about one screen starts the fetch before the shopper arrives. Recommendation rows work the same way, each in a box with a reserved height so nothing above them shifts. The product-reviews problem covers sorting, filtering, and helpful votes.

Structured data for rich results

Search engines can show price, availability, and star rating in the results if the page describes the product in structured data: a JSON-LD script using the schema.org Product type with an Offer and, when there are ratings, an AggregateRating. It must match what the page shows, or search engines may ignore it. Render it on the server from the same data as the visible offer (Google recommends it in the initial HTML) and revalidate it on the same schedule. For variant families, Google also documents ProductGroup with hasVariant and variesBy.

html
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Product",
  "name": "Trail Runner 4, blue",
  "sku": "TR4-BLU-10",
  "image": ["https://img.example.com/tr4-blu-1.jpg"],
  "aggregateRating": { "@type": "AggregateRating", "ratingValue": 4.4, "reviewCount": 2318 },
  "offers": {
    "@type": "Offer",
    "price": "89.99",
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock",
    "url": "https://shop.example.com/p/tr4?color=blue&size=10"
  }
}
</script>
Product structured data, rendered on the server next to the visible price.

Check your understanding · Exercise 5

A product page has a poor LCP of 4.1 s on phones. Which changes are likely to help (select all that apply)? Select all that apply.

Price and stock freshness: honest numbers through many caches

Caching made the store fast. It also means a price can be wrong in five places at once. This chapter decides how stale each place may be, how a change travels through the layers, and what the page says when it isn't sure.

The failure, told as a story

A lightning deal drops a TV from $499 to $349 at 10:00 and ends at 12:00, when the price goes back to $499. In a naive design, the product page shell is cached for 5 minutes with the price inside it, and a shopper's tab has been open since 11:50. At 12:03 they press Add to cart and later see $499 in checkout.

They feel tricked, and in some countries showing one price and charging another is a legal problem, not only a support ticket. The opposite failure also happens: a page shows "In stock" from a cache, and the item sold out an hour ago.

The lesson: you can't make every layer fresh, so make each layer as fresh as its job needs, push big changes through fast, and check for real at the moments that move money.

Cache layers for price and stock. The price service is the source of truth. It feeds the search index, which is minutes behind and feeds listing badges that are only a guide. It feeds the offer API, which reads live. The offer API feeds the CDN, which keeps the offer fragment for 30 seconds, and the CDN feeds open tabs, which refetch when the shopper returns. The price service also purges the CDN by tag. The offer API feeds cart and checkout directly with a live price check on every view.Price servicesource of truthOffer APIreads liveCart, checkoutlive price checkSearch indexminutes behindCDNoffer: 30 sOpen tabrefetch on focusListing badgesa guide onlyliveeventspurge tag
Each layer has a bounded age, and only cart and checkout read live. The purge makes planned changes fast; the 30-second lifetime is the safety net when a purge is lost.
LayerTypical stalenessWhat it's allowed to show
Search index (listing results)seconds to minutes behind the price servicePrice and "in stock" badges, as a guide only
CDN copy of the product shellup to s-maxage (we use 300 s), less after a purgeTitle, photos, description. No price inside it.
CDN copy of the offer fragmentup to 30 s, less after a purgePrice, stock bucket, buy box
Data in an open tabas old as the tab, unless refreshedWhatever was fetched last; refreshed on return to the tab
Cart lineprice at the time of addingRe-priced on every cart view, with a note when it changed
Checkout review stepnone: computed by the server on load and again on Place orderThe price the shopper agrees to pay

Decision How do price changes reach the shopper's screen?

Never cache anything that shows a price

Every page with a price is rendered per request from live data.

Pros

  • Always fresh.

Cons

  • Almost every page shows a price, so almost nothing is cached; a sale peak goes straight to the price service.

Cache for a long time and purge on every change

Responses carry tags such as sku:TR4-BLU-10; the price service emits an event and the CDN purges by tag.

Pros

  • High hit rates when prices are calm; fresh within seconds of a purge.

Cons

  • A missed event leaves a wrong price up for hours.
  • During a sale, millions of price changes become a purge storm.

Short lifetime on the offer fragment, tag purge for big changes, and a real check at money moments

The offer fragment lives 30 s with stale-while-revalidate.

Deal starts and ends, and recalls, also purge by tag.

Add to cart and checkout always read live prices from the server.

Pros

  • Worst case staleness on browse pages is bounded even if an event is lost.
  • Money is never decided from a cache.

Cons

  • More requests to the offer service than with long caching.
  • The shopper can still see a price change between page and cart, so the UI must explain it.

What we pick: Short lifetimes plus tag purges plus a live check. The short lifetime is the safety net when a purge event is lost; the purge makes planned changes fast; the live check at add-to-cart and checkout makes money correct. Many large CDNs offer purging by tag, under names like surrogate keys (Fastly) or cache tags (Cloudflare, Akamai, and since 2026 CloudFront); check what yours supports. The "Cache invalidation" and "CDN caching" lessons go deeper on each piece.

Showing stock honestly

  • Show buckets, not exact counts: "In stock", "Only 3 left", "Out of stock", "Back in stock on 14 March". A bucket changes less often than a count, so caches stay valid longer, and it doesn't claim precision the page doesn't have.
  • Show "Only N left" only from the fresh offer fragment, never from the listing index. Urgency built on stale data is both wrong and a dark pattern.
  • Let add-to-cart correct the page. If the server says only 1 is left, the response says so, the cart line is set to 1, and the page shows a short message near the button. Never silently fail.
  • Refresh on return. Open tabs are the stalest layer. On visibilitychange to visible, if the offer data is older than about 60 s, refetch it. Server-state libraries like TanStack Query do this with a stale time and refetch on window focus.
  • Use server time for deals. A countdown for a deal ending at 12:00 must use the server's clock. Send the server time with the offer, compute the offset from the device clock once, and count down with that offset. Devices with wrong clocks are common.
A deal price of 349 dollars ends at 12:00 and the price returns to 499. The price service switches at 12:00. The CDN's offer fragment can show 349 for up to 30 more seconds. A tab open since before noon shows 349 until the shopper returns to it at 12:03 and it refetches. A few seconds after 12:03, checkout computes 499 live and shows the change before Place order.price svc$349$499CDN offer$349$499open tab$349$499checkout$499live price, shown before Place order11:5812:0012:0212:04
Each layer is wrong for a different, bounded time. The open tab is the stalest, which is why refetch on focus and the live price at checkout both exist.

Check your understanding · Exercise 6

The deal on the TV ends at 12:00. Which parts of the design make sure no shopper pays the deal price after 12:00 by accident, or pays $499 while thinking it's $349 (select all that apply)? Select all that apply.

Cart and checkout: taking money exactly once

The cart, in brief

The shopping-cart guide designs the cart in depth, so here is only what the rest of the store depends on. The cart lives on the server, keyed by a guest cart ID in a cookie or by the account, and merges at sign-in. Quantity changes are optimistic with a rollback, and requests for the same line are sent in order.

Every cart view re-prices every line and shows changes in words. The header's cart count comes from a tiny private, no-store endpoint, so it never pollutes a cached page. And Add to cart on the product page is a real form with a POST, so it still works if the script hasn't loaded yet.

Checkout as a multi-step form

Checkout collects four things: a shipping address, a delivery option, a payment method, and a final confirmation. A returning shopper has all of them saved and should go straight to a review page with one button. A first-time guest needs to fill in all four, on a phone, without losing anything if they switch apps to find their card.

Decision How is checkout laid out?

One long form on one page

Address, delivery, and payment fields all on one scrolling page.

Pros

  • No step navigation; easy to see everything.

Cons

  • Overwhelming on a phone; delivery options depend on the address, so half the page can't be filled until the top is valid.

Separate pages per step

/checkout/address, /checkout/delivery, and so on, each submitting to the server.

Pros

  • Each step is small and works without JavaScript.
  • Back and refresh work naturally.

Cons

  • A returning shopper with everything saved still walks through four pages.

One review page with collapsible sections, saved to a server-side checkout session

Every section shows a summary when filled ("Ship to Jane, 12 Elm St") and opens into a form when changed.

Each section saves to a checkout session on the server when completed.

Pros

  • Returning shoppers see a filled review page and press one button.
  • New shoppers fill one open section at a time.
  • Refresh, app switch, and 3-D Secure redirects lose nothing: the session holds it.

Cons

  • More states to design: each section can be empty, open, saved, or invalid after another change (a new address can change delivery options).

What we pick: Collapsible sections over a server-side checkout session. It gives the one-button path to returning shoppers and a guided path to new ones, and the session ID in the URL (/checkout/cs_81f2) lets the page rebuild itself after a redirect or a refresh. The checkout-flow and multi-step-form problems go deeper on the steps; the "Form state" lesson covers validation timing.

Address entry and autocomplete

  • Country first. The country decides the fields: US addresses need a state and ZIP code, UK addresses a postcode and no state, Japanese addresses run from large to small. Changing country re-renders the form from a per-country field list.
  • Let the browser autofill. Correct autocomplete tokens such as shipping given-name, shipping address-line1, shipping postal-code, and shipping country let the browser fill the form in one tap. It's the cheapest conversion win in checkout.
  • Address suggestions as a combobox. An address lookup API suggests full addresses after a few characters, debounced, following the autocomplete guide's pattern. The shopper can always ignore it and type freely: lookup databases miss new buildings.
  • Validate on the server and suggest, don't block. If the address service thinks "12 Elm Street" should be "12 Elm St, Apt 4", show both and let the shopper choose.

Payment: hosted fields keep card numbers off our pages

If our own JavaScript can read the card number, then any script on the page can too: an analytics tag, a compromised dependency, an injected skimmer. Attacks that inject card-skimming code into checkout pages (often called Magecart attacks) have hit large retailers. So the card fields are hosted fields: small iframes served by the payment provider (Stripe, Adyen, Braintree, and others offer them). The card number is typed into the provider's iframe and sent straight to the provider, which returns a token that is all our page ever sees.

This also shrinks our PCI DSS scope (PCI DSS is the card industry's security standard). With hosted fields or a hosted payment page, card data never touches our servers or our scripts, so far fewer requirements apply. A smaller merchant can often validate with SAQ A, the shortest self-assessment questionnaire; a store this size is a Level 1 merchant that needs a yearly assessment by a qualified assessor anyway, but with much less to cover.

PCI DSS 4.0 (now 4.0.1) added two script rules, mandatory since March 31, 2025: 6.4.3 (inventory and authorize every script on payment pages) and 11.6.1 (alert on unauthorized changes to those scripts and to security headers). For iframe integrations, SAQ A replaces them with an eligibility check that the site is not open to script attacks, met with similar controls or the provider's written confirmation. Confirm what applies with the provider and your assessor. Wallets such as Apple Pay and Google Pay come through the same SDK.

Place order, exactly once

The scariest bug in a store is a double charge. It happens in ordinary ways: a double click, a slow network where the shopper presses again, a request that succeeded on the server but whose response was lost, a retry library that re-sends a POST. Disabling the button stops the first case only.

The real fix is an idempotency key: a random ID the client creates once per checkout attempt and sends with every try of the same order. The server stores the key with the result, and a repeat with the same key returns the first result instead of charging again.

ts
const key = checkout.attemptKey ?? (checkout.attemptKey = crypto.randomUUID());

async function placeOrder(): Promise<OrderResult> {
  for (let attempt = 0; attempt < 3; attempt++) {
    try {
      const res = await fetch(`/api/checkout/${checkout.id}/orders`, {
        method: "POST",
        headers: { "Idempotency-Key": key, "Content-Type": "application/json" },
        body: JSON.stringify({ expectedTotal: checkout.total }),
        signal: AbortSignal.timeout(15_000),
      });
      return await res.json(); // an order status, or a problem+json error such as price-changed
    } catch {
      await sleep(500 * 2 ** attempt); // same key: the server dedupes
    }
  }
  return { status: "unknown" }; // UI polls GET /orders?key=... before saying anything
}
One key per attempt, reused on retries; a lost response is resolved by asking, not by re-sending blindly.

Note expectedTotal: the client sends the total it showed. If the server's live total differs (a price changed, a coupon expired), it refuses with a 409 and the new numbers, the client moves to its price_changed state, and the review page shows the difference before the shopper tries again. That's the freshness chapter's rule at work: money is never decided from a cache. The state machine shows every state the Place order button can be in.

The Place order state machine. From review, PLACE goes to submitting. From submitting: 201 goes to confirmed; 409 goes to price_changed, which goes back to review to show the difference; 3DS goes to needs_3ds; a timeout goes to checking, which asks for the status by key and moves to the state the server reports, for example declined. From needs_3ds, bank ok goes to confirmed and bank no goes to declined, which returns to payment with a new key.reviewsubmittingcheckingstatus by keyprice_changedneeds_3dsdeclinedconfirmedPLACEtimeout409show diff3DSbank nobank ok201statusdeclined: back topayment, new key
Submitting has no PLACE event, so a second click does nothing. A timeout never means failure: checking asks the server with the same idempotency key before the page says anything.

3-D Secure: when the bank wants a word

In the EU and UK, strong customer authentication (SCA) rules mean many card payments need a check by the shopper's bank, done through 3-D Secure (3DS). With 3DS2, the provider first sends device and order data to the bank. If the bank judges the payment low risk, it approves with no shopper action (the frictionless flow); otherwise it asks for a challenge, such as an app approval or a one-time code.

For a challenge, the provider's API returns requires_action, and its SDK shows the bank's page in a modal iframe or, for some banks and payment methods, redirects the whole page to the bank and back to a return URL. A redirect wipes all in-memory state. That's why the checkout session lives on the server: the return URL carries the session ID, and the page asks the server for the order's status instead of trusting anything in memory. The sequence diagram shows the redirect path, ending in confirmed or declined.

3-D Secure with a redirect, between the Browser, our BFF, the payment Provider, and the Bank. The browser posts the order with an idempotency key. The BFF confirms the payment with the provider, which answers requires_action, and the BFF passes that back. The browser goes to the bank's challenge page. The bank sends the shopper back to the return URL with the checkout session ID. The browser asks the BFF for the order status, the BFF checks with the provider, and the BFF answers confirmed, or declined with a retry.BrowserBFFProviderBankPOST order + keyconfirm paymentrequires_actionrequires_actionredirect to bankback, cs_81f2GET statusread paymentconfirmed, or declined
The redirect wipes everything in memory. The return URL carries the checkout session ID, and the page asks the server what happened instead of trusting the URL or any client state.
What went wrongWhat the shopper seesWhat the client does
Card declined"Your bank declined this card. Try another card." on the payment sectionKeeps address and delivery; opens payment; same checkout session, new attempt key
3DS failed or cancelled"The bank check wasn't completed." with a retry buttonAsks the server for status; offers retry or another method
Price changedThe review page with the old and new price side by sideShows the new total; Place order needs a new click
Item went out of stockThe line marked unavailable with a Remove buttonBlocks Place order until resolved
Response lost (timeout)"Checking your order..." then the real resultPolls status by idempotency key; never re-charges

Check your understanding · Exercise 7

The shopper presses Place order, the request times out after 15 seconds, and the page doesn't know if the order went through. What should happen (select all that apply)? Select all that apply.

Marketplace, recommendations, and testing everything

Many sellers, one product

On a marketplace, the product and the thing you buy are different objects. The product is the catalog entry (title, photos, description, specs, reviews), shared by everyone who sells it. An offer is one seller's promise to sell it: a price, a condition (new, used), a stock level, a shipping speed, who ships it, and who sells it.

A popular phone case can have 20 offers. This split drives the page and the cart: the page caches the product for minutes but treats offers as fresh data, and a cart line points to an offer ID, not a product ID, because the price and the seller come from the offer.

Shoppers don't want to compare 20 offers, so the page features one of them in the purchase panel, a spot widely called the buy box (Amazon's seller help pages now call it the Featured Offer). Which offer wins is decided on the server from things like price including delivery, delivery speed, stock, and seller performance. The exact rules are Amazon's and aren't public, so in an interview say "the server picks it" and don't invent a formula. The frontend's jobs are clear, though:

  • Render the featured offer as part of the fresh offer fragment, since it can change when stock or prices change, and it can depend on the delivery region.
  • Say who sells and who ships: "Ships from Store, sold by Acme Gadgets", with the seller's rating (for example, percent positive over the last 12 months) linking to a seller page.
  • Offer an "Other sellers" entry point with a count and the lowest price ("12 offers from $79"). The full list loads only when opened, in a side panel that's a real dialog, sorted by total price with delivery.
  • Add to cart sends the offer ID. If that offer sold out, the server says so and the page refreshes the featured offer rather than silently switching sellers.
One product, many offers. The product TR4, shared and cached, has three offers: Acme at 89 dollars 99 with delivery in 2 days, ShoeHub at 84 dollars with delivery in 6 days, and the Store at 92 dollars 50 with delivery tomorrow. All three go to the featured offer step, which the server decides. It puts Acme into the buy box. Below, the panel with all 12 offers, from 79 dollars, loads only when opened.Product TR4shared, cachedAcme$89.99, 2 daysShoeHub$84.00, 6 daysStore$92.50, tomorrowFeatured offerserver decidesBuy boxAcme, $89.99All 12 offersfrom $79, loads when opened
The cheapest offer doesn't always win: delivery speed and seller performance count too, and the real rules are the store's own. The frontend renders the result and links to the rest.

Recommendation widgets

A product page can carry five or more recommendation rows: frequently bought together, similar items, customers also viewed, sponsored products, and recently viewed. Each comes from a different service with different speed and reliability. Treat each row as an independent widget with four rules:

  1. It never blocks the page. Rows below the fold load on proximity with IntersectionObserver. A row above the fold (frequently bought together) is a streamed section with a short server timeout.
  2. It reserves its height, so a late row doesn't push content down and hurt CLS.
  3. It fails quietly. If the service errors or takes longer than its budget (say 800 ms), the row is removed or never shown. An error boundary around each row keeps one broken widget from breaking the page.
  4. It reports impressions honestly. A row counts as seen when at least half of it is visible for about a second, measured with IntersectionObserver thresholds, not when it's rendered. Sponsored items carry a visible "Sponsored" label.

A/B testing everything, with guardrails

Big stores run many experiments at the same time: button text, image size, the number of rows, the checkout layout. The "A/B testing" lesson covers statistics and assignment; three store-specific problems matter here.

  • No flicker. Client-side tools that render the control and then swap in the variant cause a visible flash, and their anti-flicker snippets hide the page until the script runs, which delays LCP. Assign the variant before rendering instead: at the edge, from a hash of the visitor ID and the experiment ID, stored in a cookie so it's stable.
  • Experiments versus the cache. If the variant changes the shared HTML, it must be part of the cache key, and 10 experiments with 2 arms each make 1,024 versions of every page. So only a few experiments may change the cached shell at once. Most should change a client-rendered island or a fragment, which keeps the shell shared.
  • Guardrail metrics. Every experiment watches metrics it must not hurt, besides its own goal: LCP, INP, and CLS from real users, JavaScript error rate, add-to-cart rate, checkout completion, and revenue per visitor. If a guardrail drops beyond a set threshold, the experiment stops automatically. Also check sample ratio mismatch: a 50/50 test that shows 52/48 means the assignment or logging is broken, and the result can't be trusted.

Think first

A test of a bigger product photo raises add-to-cart by 2 percent on desktop, but the team notices that LCP on phones got 400 ms worse in that arm. Ship it?

Check your understanding · Exercise 8

Which statements about offers and the buy box are right (select all that apply)? Select all that apply.

Architecture: the parts and why the lines are where they are

With the big decisions made, we can draw the system. The frontend of a store is not only code in the browser: it includes the edge logic, the render servers, and a backend for frontend (BFF) that shapes data for pages. The design diagram shows the parts; the table says what each one owns.

Architecture. In the browser: the page shell, interactive islands for filters and variants, a query cache for offers and results, the cart and checkout client with the idempotent order, and the payment provider's iframe. At the edge: edge logic for segment, A/B, and bots, and the CDN cache for shells and fragments. On our servers: the render server and BFF with SSR and streaming. Behind it: catalog, search, offers and stock, reviews and recommendations, cart and orders, and the payment provider. The shell gets HTML from the edge, the query cache gets fragments, cart and checkout talk to the BFF, the edge passes misses to the render server, which calls the services in parallel, and the payment iframe talks straight to the provider.BrowserPage shellIslandsfilters, variantsQuery cacheoffers, resultsCart, checkoutidempotent orderPayment iframeprovider fieldsEdgeEdge logicsegment, A/B, botsCDN cacheshells, fragmentsOur serversRender + BFFSSR, streamingServicesCatalogSearchOffers, stockReviews, recsCart, ordersPaymentsprovidercard data
Two boundaries matter most. The edge decides the cache key and serves shared copies, so peaks hit the CDN. The payment iframe talks to the provider directly, so card numbers never pass through our code.
PartRuns whereOwns
Edge logicCDN edgeMarket and language routing, the segment header, A/B assignment, bot scoring, cache key rules
CDN cacheCDN edgeShared HTML shells, offer fragments, static JS and CSS, images (through an image CDN)
Render server and BFFOur servers, near the dataServer rendering and streaming per page type; calls catalog, search, offers, reviews, and recommendations in parallel and shapes one response per page
Page shellsServer, then browserLayout, header, footer, and the static parts of each page type
Interactive islandsBrowserFilter panel, variant picker, gallery and zoom, Add to cart, other sellers panel, review list, recommendation rows
Server-state cacheBrowserFetched data with its age: offers, reviews, search results; dedupes and refetches on focus
Cart clientBrowserCart count and mini cart; optimistic updates; talks to the cart service through the BFF
Checkout clientBrowserSection state of the checkout session; idempotent Place order; 3DS handling
Payment SDKProvider iframesCard fields, wallets, 3DS challenge; returns tokens
Telemetry and experimentsBrowser and edgeWeb Vitals from real users, errors, impressions, exposure events

Why a BFF, and why it renders near the data

A product page needs data from six services: catalog, offers, inventory, reviews summary, recommendations, and the shopper's delivery estimate. Six calls from a phone on slow 4G, where each round trip can take 100 ms or more, would push LCP far past budget, especially when one call depends on another. The BFF makes those calls inside the data center, in parallel, where a round trip is a millisecond or two, and returns one shaped response or streams HTML.

Putting the render server next to the BFF and the services (not at the edge) keeps those calls fast; the edge does the cheap per-request decisions. The "Backend for frontend" lesson covers the pattern.

Many teams, one site

A store this big has a team for search, one for the product page, one for checkout, one for recommendations, and more. The frontend has to let them ship independently without the page turning into five frameworks. A practical split: each page group (search, product, checkout) is its own deployable app behind the same domain, routed at the edge, sharing a design system package and a small shared shell (header, cart count, footer).

Inside a page, other teams own slots: recommendation rows, sponsored placements, reviews. Each slot is a server component or a fragment with its own timeout and error boundary, so one team's outage removes a row instead of the page. Full runtime micro-frontends (several frameworks loaded in one page) cost too much JavaScript for a store where page speed moves sales.

How data flows on a product page

Follow one request. The shopper taps a search result, and the edge adds the segment and experiment headers and checks the CDN. The shell is a hit: HTML often starts streaming within about 100 ms, with the hero image preloaded. Right after the HTML arrives, the browser asks for the offer fragment (30 s cache) and the delivery estimate (private, no-store, never cached).

The islands hydrate, the variant picker reads the matrix from the HTML, and the reviews and recommendations start loading as the shopper scrolls. On Add to cart, the request goes through the BFF to the cart service, and the response updates the cart count and corrects stock if needed.

Data flow for a product page. Requests go down: the browser sends GET /p/tr4 to the edge, the edge adds the segment and asks the CDN, a miss goes to the BFF, and the BFF makes 6 calls to the services. Replies come back up: the services return data, the BFF streams HTML, the CDN stores a cached copy, and the edge streams HTML to the browser. Then the browser hydrates its islands, which fetch the offer fragment cached for 30 seconds and the private delivery date, and send Add to cart as a POST through the BFF that updates the cart count.BrowserEdgeCDNBFFServicesGET /p/tr4+ segmentmiss6 callsHTML streamcached copyHTMLdataIslandshydrateoffer: GET, CDN 30 sdelivery: GET, privateadd to cart: POST, BFF
On a cache hit the lower half never runs: the CDN answers and the BFF and services see nothing. The fragments and writes on the right run on every view, which is why they are small.

Check your understanding · Exercise 9

Why does the design render product pages near the data instead of at the CDN edge (select all that apply)? Select all that apply.

Data model: what the client holds, and where

The client never owns the truth about prices, stock, or orders; it holds copies of different ages. So for each entity the useful questions are: where does the client keep it, how old may it be, and who may change it. The table answers those; the code shows the shapes.

EntityWhere it lives in the clientFreshnessNotes
Search state (query, filters, sort, page)The URLAlways current: it is the stateNormalized by one function; parsed on the server and the client the same way
Search results pageServer-state cache, keyed by the normalized URLMinutesResults plus facet counts plus total; replaced as a whole when the URL changes
Product (family) and SKUsServer HTML and an embedded JSON payloadMinutesIncludes the variant matrix with stock buckets
Offer (price, stock, seller, delivery)Server-state cache, keyed by SKU and region30 to 60 s, refetched on tab focusWins over the matrix when they disagree
ReviewsServer-state cache, pagedMinutesSummary is in the HTML; the list loads lazily
CartServer; client keeps a cached copy and the countLive; re-priced on every viewLines point to offer IDs
Checkout sessionServer; its ID is in the URLLiveSurvives refresh and 3DS redirects
Recently viewedServer for accounts; localStorage for guests, capped at 50 IDsInstantIDs only; details fetched when shown
Experiment assignmentsCookie set at the edgeStable for the experiment's lifeRead on the server for rendering, on the client for exposure logging
ts
type Money = { amount: number; currency: string }; // 8999 + "USD" = $89.99

type Product = {
  id: string; title: string; brand: string;
  dimensions: { name: "color" | "size" | string; values: string[] }[];
  skus: Sku[]; // the variant matrix
  rating: { average: number; count: number; histogram: number[] };
};

type Sku = {
  id: string;
  options: Record<string, string>;      // { color: "blue", size: "10" }
  images: string[];
  listPrice: Money;                      // may be minutes old
  stock: "in" | "low" | "out" | "preorder";
};

type Offer = {
  id: string; skuId: string; sellerId: string;
  price: Money; shipping: Money; condition: "new" | "used" | "refurbished";
  stock: "in" | "low" | "out"; lowCount?: number;
  delivery: { earliest: string; latest: string }; // ISO dates
  featured: boolean;
  fetchedAt: number; serverNow: number; // for age checks and countdowns
};

type CartLine = { offerId: string; quantity: number; unitPrice: Money; priceChangedFrom?: Money };
Client-side shapes. Money is an integer in minor units plus a currency, never a float.
The data model. A Product, kept in the HTML for minutes, has many SKUs, the variant matrix in the HTML. Each SKU has many Offers, cached for 30 seconds, and each offer is sold by a Seller, cached for hours. A CartLine, on the server and live, points to an offer. The URL state is the key for the cached Search results. The Checkout session, on the server with its ID in the URL, holds the cart lines.ProductHTML, minutesSkumatrix in HTMLOffercache, 30 sURL statethe URLCartLineserver, liveSellercache, hoursSearch resultscache by URLCheckoutserver, id in URL1..n1..nsold bypoints tokeyholds lines
Cart lines point to offers, not products, because price and seller live on the offer. The URL is both state and cache key for search, and the checkout session ID in the URL is what survives a 3-D Secure redirect.

Two choices in that code matter. Money as integer minor units (cents, pence) avoids floating point errors: 0.1 + 0.2 is not 0.3 in JavaScript, and a cart total that's off by a cent fails reconciliation. Some currencies have no minor unit (the Japanese yen) and a few have three decimals, so the number of decimals comes from the currency code, not a hard-coded 100. Stock as a bucket with an optional lowCount lets caches stay valid longer and makes the UI say only what it knows.

Check your understanding · Exercise 10

Which of these belong in the URL rather than in component state or local storage (select all that apply)? Select all that apply.

Interface: the APIs and the main components

The browser talks to the BFF over plain HTTP with JSON. REST fits well here: the resources are clear (search, product, offers, cart, checkout), most reads are cacheable GETs keyed by URL, and CDNs understand them. A GraphQL BFF is also a fine answer if the company already runs one; the point is that page-shaped responses come from one place. Here are the shapes that matter.

http
GET /api/search?q=running+shoes&brand=asics,nike&size=10&sort=price-asc&page=2
Accept-Language: en-US

200 OK
Cache-Control: public, max-age=0, s-maxage=60, stale-while-revalidate=120
{
  "total": 214,
  "page": 2, "pageSize": 48, "pageCount": 5,
  "items": [{ "productId": "tr4", "title": "Trail Runner 4", "image": "...",
             "price": { "min": { "amount": 7999, "currency": "USD" }, "max": { "amount": 11999, "currency": "USD" } },
             "rating": 4.4, "ratingCount": 2318, "badges": ["fast-delivery"], "sponsored": false }],
  "facets": [{ "key": "brand", "label": "Brand", "values": [
    { "value": "nike", "label": "Nike", "count": 1204, "selected": true },
    { "value": "asics", "label": "Asics", "count": 830, "selected": true }] }],
  "canonical": "/c/running-shoes?brand=asics,nike"
}
Search: the query string is the normalized filter state, so the CDN can cache it.
http
GET /api/skus/TR4-BLU-10/offers?region=us-ca-94

200 OK
Cache-Control: public, max-age=0, s-maxage=30, stale-while-revalidate=30
{
  "serverNow": "2026-03-14T11:52:03Z",
  "featured": { "id": "of_77", "seller": { "id": "s_9", "name": "Acme Gadgets", "positivePercent": 97 },
                "price": { "amount": 8999, "currency": "USD" }, "stock": "low", "lowCount": 3 },
  "others": { "count": 11, "lowestTotal": { "amount": 7900, "currency": "USD" } },
  "deal": { "endsAt": "2026-03-14T12:00:00Z", "claimedPercent": 72 }
}

GET /api/delivery-estimate?offer=of_77
Cache-Control: private, no-store
{ "earliest": "2026-03-16", "latest": "2026-03-17", "orderWithin": "PT2H14M" }
Offers for one SKU in one delivery region: short shared cache. Delivery for this shopper: private.
http
POST /api/checkout/cs_81f2/orders
Idempotency-Key: 4b8e6a0c-2f1d-4c55-9a7e-0d2c1f3b9e10
Content-Type: application/json
{ "expectedTotal": { "amount": 9873, "currency": "USD" }, "paymentToken": "pm_tok_..." }

201 Created     { "status": "confirmed", "orderId": "o_5521" }
200 OK          { "status": "requires_action", "action": { "type": "3ds", "clientSecret": "..." } }

409 Conflict
Content-Type: application/problem+json
{ "type": "https://shop.example.com/errors/price-changed", "title": "The total changed",
  "newTotal": { "amount": 10473, "currency": "USD" }, "lines": [{ "offerId": "of_77", "from": 8999, "to": 9599 }] }

402 Payment Required
{ "type": "https://shop.example.com/errors/card-declined", "title": "The card was declined", "retryable": true }
Placing the order: idempotent, with the total the shopper saw. Errors use one problem-details format.

Errors use one format, application/problem+json (RFC 9457, problem details for HTTP APIs), with a stable type URL the client can switch on, so the checkout code maps price-changed to the review diff and card-declined to the payment section without parsing messages. The "API contracts and errors" lesson covers versioning and error design.

The two most interesting components

tsx
type FacetPanelProps = {
  facets: Facet[];                       // from the search response, with counts
  selected: Record<string, string[]>;    // parsed from the URL
  mode: "instant" | "apply";             // desktop applies at once; phone drawer waits
  onChange: (next: Record<string, string[]>) => void; // caller builds the URL and navigates
  pendingCount?: number;                 // "Show 214 results" in apply mode
};

type VariantPickerProps = {
  dimensions: Product["dimensions"];
  skus: Sku[];                           // matrix: which combinations exist and their stock bucket
  value: Record<string, string>;         // from the URL
  onSelect: (dimension: string, value: string) => void;
  freshOffer?: Offer;                    // overrides the matrix for the selected SKU
};
The filter panel is controlled by the URL; the variant picker by the matrix plus the fresh offer.

Both components are controlled: they don't keep their own copy of the selection. The page owns the selection through the URL and passes it down. That's what makes Back, sharing, and server rendering agree. Accessibility-wise, each facet group is a fieldset with a legend and real checkboxes; the variant picker is a radio group per dimension (color swatches are radios with visible text or an accessible name, not color alone), and unavailable values stay focusable and announce "out of stock".

Check your understanding · Exercise 11

Which response headers and statuses fit these endpoints (select all that apply)? Select all that apply.

Core Web Vitals and conversion

Everyone in e-commerce has heard that speed is money, so be careful which numbers you quote. The famous line that every 100 ms of latency cost Amazon 1 percent of sales traces back to a 2006 presentation and blog posts by Greg Linden, a former Amazon engineer, describing internal tests. The data was never published, so call it an anecdote if you use it.

Better-sourced evidence exists. A 2020 Deloitte study commissioned by Google found that a 0.1 second faster mobile site went with 8.4 percent more retail conversions in its sample, and web.dev's case studies include Vodafone, which reported 8 percent more sales after a 31 percent LCP improvement in an A/B test. The honest summary: faster pages tend to convert better, the size of the effect depends on the site, and you measure your own with experiments.

LCP on a mid-range phone, before and after. Before: HTML until 0.6 seconds, then a 1.6-second JavaScript bundle, then the API call until 3.0 seconds, then the image until 4.1 seconds, which is the LCP. After: HTML from the CDN in 0.4 seconds, then the preloaded hero image until 1.6 seconds, which is the LCP, while hydration runs in parallel until 1.9 seconds.beforeHTMLJS 1.6 sAPIimageLCP 4.1 safterHTML from the CDN, 0.4 simageLCP 1.6 shydrate0 s1 s2 s3 s4 s
The fix is not a faster API but removing the chain. With the image in the server HTML and fetchpriority="high", the browser fetches it while the JavaScript is still loading. Illustrative numbers.

Budgets per page type

PageLCP target (p75, phone)JavaScript budget (compressed)Main risks
Product page2.0 sabout 150 KB for the first viewHero image found late, a carousel that hydrates first, third-party tags
Listing2.2 sabout 170 KB (filters are interactive)Too many product images marked eager, facet panel re-rendering the whole grid
Home2.2 sabout 150 KBHero banner chosen by a client-side experiment, personal rows shifting layout
Checkout2.5 sabout 120 KB plus the payment SDKPayment SDK size, form validation on every keystroke

These budgets are our own targets, not industry rules. What matters is that each page type has one, that CI fails a change that breaks it, and that real-user data (collected with the web-vitals library from the field) is split by page type and device. A single site-wide LCP number hides the product page that matters most.

INP: filters and variant clicks must feel instant

  • Paint the click first, compute later. When a filter is ticked, the checkbox and a loading hint update in the same frame; the expensive grid re-render runs as a non-urgent update (startTransition in React) so it can't block the next paint.
  • Keep handlers small. Analytics calls in a click handler should be queued (for example with requestIdleCallback or after a setTimeout), not run before the UI updates.
  • Break long tasks. Hydrating 48 product cards at once on a slow phone can take hundreds of milliseconds. Cards are mostly static: hydrate only the interactive parts (wishlist button, quick add), not the whole card.

Make the next page fast before it's asked for

Many product page views start from a listing page, so the listing can prepare the next page early. The Speculation Rules API lets the page declare which links to prefetch or even prerender, triggered by hover or by being in view. Prerendering a product page when the shopper hovers its card can make the next LCP almost instant.

Two cautions: prerender only a few likely links, since each one costs the server a render, and make analytics count a page view only when the prerendered page is actually shown (document.prerendering and the prerenderingchange event exist for this). Chromium-based browsers support the rules; Safari has an implementation that is off by default, and other browsers support at most prefetch. Browsers that don't act on the rules ignore them, so it's a safe progressive enhancement.

Third-party scripts

Marketing wants tags for ads, analytics, heatmaps, chat, and reviews. Each one adds bytes, main-thread work, and risk. The rules from the "Third-party scripts" lesson apply with extra force here.

Every tag has an owner and a budget, and tags load after the page is interactive unless there's a measured reason. Heavy widgets such as chat use a facade (a static button that loads the real widget on click), and no marketing tags run on checkout pages at all. Server-side tagging (sending events from our server to vendors) removes many browser tags entirely.

Check your understanding · Exercise 12

Real-user data shows INP of 450 ms on listing pages on phones, mostly on filter clicks. Which fixes target the cause (select all that apply)? Select all that apply.

Accessibility and many markets

Accessibility where stores usually fail

Accessibility in a store is about money too: a shopper who can't pick a size can't buy. Generic advice (alt text, contrast) applies, but these are the store-specific spots that fail most often:

  • Filters: each group is a fieldset with a legend; values are real checkboxes with the count in their label; after results update, a polite live region announces the new total. Focus stays on the checkbox the shopper used.
  • Prices: a struck-through old price is read by most screen readers without any "was", so "$129 $89" is ambiguous. Add visually hidden text: "was $129, now $89". Price per unit ("$0.42 per ounce") needs real words, not only a slash.
  • Variant swatches: color swatches are radio buttons with names ("Blue", not just a blue circle), so they work for screen readers and for color-blind shoppers. Unavailable values stay focusable and say "out of stock".
  • Gallery and zoom: thumbnails are buttons with alt text that describes the view ("side view"); the full-screen viewer is a modal dialog with a close button, Escape to close, and focus returned to the thumbnail.
  • Checkout errors: on a failed submit, move focus to an error summary at the top that links to each field, and tie each message to its field with aria-describedby. Never rely on red borders alone.
  • Targets and motion: tap targets at least 24 by 24 CSS pixels (WCAG 2.2 target size, level AA); auto-rotating home banners pause on hover and focus, have a pause button, and respect prefers-reduced-motion.

Currencies, locales, and markets

A global store runs separate markets: the US, Germany, Japan, the UAE. Each has its own catalog availability, prices in its own currency, tax rules, and languages (the UAE store is in English and Arabic). The lessons "Internationalization: performance and SEO" and "Locale formatting" go deep; the store decisions are these:

  • Market and language in the URL or domain (/de-de/ or a country domain), never only from a cookie or the Accept-Language header, so each version is crawlable and cacheable. hreflang links tell search engines which page is for which locale.
  • The server decides the currency and price. Prices in each market are set in that currency, often with charm pricing (49.99 EUR, not a converted 49.37). Never convert prices in the browser with a live exchange rate: the charged amount must match.
  • Format with Intl. Intl.NumberFormat with style: "currency" gets symbols, separators, and decimals right per locale (1.234,56 € in Germany, ¥1,235 in Japan with no decimals). Dates for delivery use Intl.DateTimeFormat, and "order within 2 hours" uses Intl.RelativeTimeFormat.
  • Right-to-left layouts. Arabic pages set dir="rtl", and CSS uses logical properties (margin-inline-start, not margin-left) so one stylesheet flips correctly. Image galleries and carousels swap their arrow directions.
  • Text grows. German labels often run up to about 30 percent longer than English. Buttons and filter labels must wrap, not overflow.
  • The cache key includes the market and language, which the URL already does. Translations are split per route so a page loads only the strings it uses.
ts
export function formatMoney(m: Money, locale: string): string {
  const fmt = new Intl.NumberFormat(locale, { style: "currency", currency: m.currency });
  const digits = fmt.resolvedOptions().maximumFractionDigits ?? 2; // 0 for JPY, 2 for USD
  return fmt.format(m.amount / 10 ** digits);
}

formatMoney({ amount: 8999, currency: "USD" }, "en-US"); // "$89.99"
formatMoney({ amount: 4999, currency: "EUR" }, "de-DE"); // "49,99 €"
formatMoney({ amount: 1235, currency: "JPY" }, "ja-JP"); // "¥1,235"
Format money from minor units with the currency's own number of decimals.

Check your understanding · Exercise 13

Which choices are right for a store selling in Germany, Japan, and the UAE (select all that apply)? Select all that apply.

Security: locked-down checkout, careful scripts, and bots

A store is a target for three kinds of attackers: those who want card numbers, those who want accounts, and bots that want prices or limited stock. The frontend can't stop them alone, but it decides how much damage a single mistake can do.

Content Security Policy on checkout

A Content Security Policy (CSP) is a response header that tells the browser which scripts, frames, and connections a page may use. On checkout it's the main defense against a skimming script: even if an attacker injects a script tag, the browser refuses to run it. Use a strict, nonce-based policy: every script tag the server renders carries a random nonce that changes per response, and strict-dynamic lets those trusted scripts load what they need. The "Content Security Policy" lesson covers the directives in depth.

http
Content-Security-Policy:
  script-src 'nonce-r4nd0m' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
  frame-src https://js.payments.example https://hooks.payments.example;
  connect-src 'self' https://api.payments.example;
  form-action 'self';
  frame-ancestors 'none';
  report-to csp-endpoint
A strict policy for checkout pages. Provider domains come from the provider's own CSP documentation.
  • Roll it out in report-only mode first (Content-Security-Policy-Report-Only) and watch the reports for a week. A strict CSP that blocks the payment SDK's 3DS frame takes checkout down.
  • frame-ancestors 'none' stops other sites from framing checkout and tricking clicks (clickjacking).
  • Reviews and Q and A are user content. Render them as text, never as HTML; if rich text is needed, sanitize on the server with an allow-list. Trusted Types, where the browser supports them, can lock down the remaining DOM sinks such as innerHTML.
  • Cookies for the session are HttpOnly, Secure, and SameSite=Lax; state-changing requests (add to cart, place order) also carry a CSRF token or check the Origin header.

Bots, scrapers, and scalpers

Two kinds of bots matter. Scrapers read prices and stock all day, sometimes more requests than real shoppers; they mostly cost money and load. Scalpers try to buy limited items, such as a console launch, faster than any human, then resell them. The basic defenses are layered, and most of them live at the edge and the server, not in the page:

  1. Let the cache absorb reads. Scrapers hitting cached pages cost little. Deep paging limits and rate limits per IP address and per session stop the worst of the rest.
  2. Score requests at the edge. CDN bot-management products look at signals such as request patterns, TLS fingerprints, and known bad networks, and tag each request with a score.
  3. Challenge only where it matters. A low score on a browse page gets a rate limit. A low score on add to cart for a limited item or on checkout gets a challenge (a CAPTCHA or an invisible check). Real shoppers rarely see one.
  4. Enforce limits on the server. "One per customer" is checked at order placement against the account, payment card, and address, never only by a disabled button.
  5. Use a queue for big drops. A virtual waiting room at the edge admits shoppers at a rate the back end can take, in arrival order, instead of letting the fastest script win.

Check your understanding · Exercise 14

Which measures actually reduce the risk of card numbers being skimmed on checkout (select all that apply)? Select all that apply.

Running it in the interview

This question is too big to finish, and the interviewer knows it. They're watching how you choose. A strong answer picks three or four deep areas, shows the trade-offs with numbers, and keeps the rest to one sentence each. Here's a plan for 60 minutes.

MinutesWhat you doWhat to draw or say
0 to 6Scope questions and assumptionsThe shopper's path as five boxes: home, search, product, cart, checkout. Write the numbers you assume next to it.
6 to 14Rendering per page typeA table of page types with rendering, cache lifetime, and personal holes. Say the cookie trap out loud.
14 to 24Architecture and data flowEdge, CDN, render server and BFF, services; the browser islands. One product page request traced end to end.
24 to 36First deep dive: listing or product pageURL as state with facet counts, or hero LCP plus variant matrix plus fresh offer.
36 to 48Second deep dive: freshness and checkoutCache layers with lifetimes; idempotent Place order; the 3DS redirect.
48 to 55Cross-cutting: performance, a11y, i18n, securityBudgets per page type, no tags on checkout, CSP, bot layers.
55 to 60Follow-ups and what you'd do nextExperiments with guardrails, team split, monitoring.

Draw the shopper's path first, because every later decision hangs off a page type. Then add the edge and the CDN in front of it before any browser detail: it tells the interviewer early that you think about where each response is cached and who it's shared with. Keep a corner of the board for the numbers you assumed (50 million products, 100,000 views per second at peak, most traffic on phones) and point back to them when you choose cache lifetimes and budgets.

Think first

Follow-up: "Black Friday brings 5 times normal traffic, and a famous deal goes live at 9:00. What do you change?"

Think first

Follow-up: "Product owners want the product page to show 'You bought this on 3 March' at the top. How, without breaking caching?"

Think first

Follow-up: "SEO says our category pages lost rankings after we switched the listing to infinite scroll. Why, and what's the fix?"

Think first

Follow-up: "Support reports a handful of double charges a week. Where do you look?"

Common mistakes

  • Picking one rendering strategy for the whole site.
  • Putting prices, stock, or anything personal into a page that the CDN shares.
  • Keeping filters in component state, or using infinite scroll without paged URLs.
  • Lazy loading the hero image, or building it with a client-side carousel.
  • Treating a disabled button as double-charge protection.
  • Quoting "100 ms costs 1 percent" as a fact.
  • Spending 20 minutes on the cart, which is a smaller question of its own.

Check your understanding · Exercise 15

You have 10 minutes left and haven't covered checkout. What's the best use of the time (select all that apply)? Select all that apply.

Summary

  • Decide rendering per page type: cached shared shells for home, listing, and product pages, private rendering for cart and checkout.
  • Never put a personal or fast-changing value into HTML the CDN shares. Turn cookies into a few segments at the edge, and fill finer details from private requests.
  • On listing pages the URL is the state: one normalizing function, facet counts that skip their own group, paged URLs behind any infinite scroll.
  • The product page's hero photo is the LCP element: plain server-rendered img, high priority, sized, never lazy. Everything below the fold loads on proximity.
  • Ship the variant matrix with the page for instant switching, then fetch the fresh offer for the selected SKU; the fresh offer wins.
  • Bound staleness per layer (short lifetimes plus tag purges) and decide money only from live data at add to cart and checkout.
  • Card numbers stay in the provider's iframes; the checkout session lives on the server so 3DS redirects and refreshes lose nothing.
  • Place order carries an idempotency key and the total the shopper saw; an unknown outcome is resolved by asking the server, never by charging again.
  • On a marketplace, products and offers are different objects: cache products, keep offers fresh, and let the server pick the featured offer.
  • Every change ships as an experiment with guardrails on Web Vitals, errors, and checkout completion, assigned before rendering to avoid flicker.

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