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.
| Question | Answer we assume | What 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.
- 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.
- 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.
- 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.
- 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.
- 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 type | Share of views | Needs SEO? | Personal parts | How fresh |
|---|---|---|---|---|
| Home | about 10% | a little (brand searches) | most of the page | minutes for promos, live for personal rows |
| Search and category listing | about 35% | yes for categories, less for free-text search | small (delivery badges, sponsored slots) | minutes for stock and price badges |
| Product page | about 45% | yes, the most | small but important (delivery date, buy box for your address) | price and stock within a minute or two |
| Cart | about 5% | no | all of it | live |
| Checkout | about 5% | no (and must not be indexed) | all of it | exact, checked by the server |
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?
The shoppers who arrive from Google onto a product page break first. They download HTML with an empty div, then a large JavaScript bundle, then call the API, then render. On a mid-range phone on 4G, that pushes LCP well past 2.5 seconds, and many of them leave before the image shows.
Search engines are second. Google can render JavaScript, but it does it in a later pass with its own budget, so new and tail products get indexed slower and less reliably than with real HTML. Other crawlers and link previews often see nothing. The fix is not "server-render everything" either: checkout gains little from it. It's choosing per page type, which is the next chapter.
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?
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
| Page | Rendering | Shared cache | Personal and fresh parts |
|---|---|---|---|
| Home | Streaming SSR of a shell cached per segment (country, language, signed in or not) | s-maxage=60 plus stale-while-revalidate | Personal rows (recently viewed, for you) fetched by the client below the fold, or streamed if above it |
| Category listing | ISR or cached SSR, one cache entry per normalized URL | s-maxage=300 | Sponsored slots and delivery badges as small fragments |
| Free-text search | SSR, cached briefly; most queries are rare, so hit rates are low | s-maxage=60 for popular queries | Same as listing |
| Product page | ISR or cached SSR shell; streaming for slow sections | s-maxage=300 for the shell | Price, stock, buy box, delivery date: a fresh fragment with its own short lifetime |
| Cart | Private SSR or CSR from a static shell | none: private, no-store | All of it |
| Checkout | Private SSR with little JavaScript, forms that work without it | none: private, no-store | All of it; the server re-prices before placing the order |
Cached SSR or ISR at the CDN
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.
# 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
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.
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;
}
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.
40 results. History has 1 entry.
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
replaceStatewhile 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.
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?
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?
Scroll position and loaded pages. If the shopper loaded three batches with load-more, the URL says ?page=3, so the server must render pages 1 to 3, and the browser must restore the scroll offset after that content exists. Client routers that render from cached data can restore scroll right away.
Even better is the back/forward cache (bfcache), which keeps the whole page in memory and restores it instantly. Avoid what blocks it: unload handlers and open connections are common causes. Cache-Control: no-store on the HTML blocks it in some browsers; Chrome began admitting most no-store pages in 2025 but evicts them when cookies change. Chrome DevTools has a panel that tells you why a page wasn't eligible.
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.
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
imgin 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 noloading="lazy". Lazy loading the LCP image is one of the most common LCP mistakes. - It has
srcsetandsizes, so a phone downloads a 600-pixel-wide file, not a 2,000-pixel one. An image CDN serves AVIF or WebP based on theAcceptheader. - It has
widthandheight(or a CSSaspect-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?
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.
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.
<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>
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.
| Layer | Typical staleness | What it's allowed to show |
|---|---|---|
| Search index (listing results) | seconds to minutes behind the price service | Price and "in stock" badges, as a guide only |
| CDN copy of the product shell | up to s-maxage (we use 300 s), less after a purge | Title, photos, description. No price inside it. |
| CDN copy of the offer fragment | up to 30 s, less after a purge | Price, stock bucket, buy box |
| Data in an open tab | as old as the tab, unless refreshed | Whatever was fetched last; refreshed on return to the tab |
| Cart line | price at the time of adding | Re-priced on every cart view, with a note when it changed |
| Checkout review step | none: computed by the server on load and again on Place order | The price the shopper agrees to pay |
Decision How do price changes reach the shopper's screen?
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
visibilitychangeto 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.
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?
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
autocompletetokens such asshipping given-name,shipping address-line1,shipping postal-code, andshipping countrylet 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.
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
}
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.
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.
| What went wrong | What the shopper sees | What the client does |
|---|---|---|
| Card declined | "Your bank declined this card. Try another card." on the payment section | Keeps address and delivery; opens payment; same checkout session, new attempt key |
| 3DS failed or cancelled | "The bank check wasn't completed." with a retry button | Asks the server for status; offers retry or another method |
| Price changed | The review page with the old and new price side by side | Shows the new total; Place order needs a new click |
| Item went out of stock | The line marked unavailable with a Remove button | Blocks Place order until resolved |
| Response lost (timeout) | "Checking your order..." then the real result | Polls 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.
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:
- 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. - It reserves its height, so a late row doesn't push content down and hurt CLS.
- 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.
- It reports impressions honestly. A row counts as seen when at least half of it is visible for about a second, measured with
IntersectionObserverthresholds, 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?
Not as is. LCP is a guardrail, and phones are most of the traffic. The likely cause is a bigger file, not a bigger box: the variant probably serves a larger image to phones too. Fix sizes so phones still get a phone-sized file, then rerun.
Also check that the 2 percent holds per device. A desktop win with a phone loss can net out to zero or worse. Good experiment reports split results by device and country for exactly this reason.
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.
| Part | Runs where | Owns |
|---|---|---|
| Edge logic | CDN edge | Market and language routing, the segment header, A/B assignment, bot scoring, cache key rules |
| CDN cache | CDN edge | Shared HTML shells, offer fragments, static JS and CSS, images (through an image CDN) |
| Render server and BFF | Our servers, near the data | Server rendering and streaming per page type; calls catalog, search, offers, reviews, and recommendations in parallel and shapes one response per page |
| Page shells | Server, then browser | Layout, header, footer, and the static parts of each page type |
| Interactive islands | Browser | Filter panel, variant picker, gallery and zoom, Add to cart, other sellers panel, review list, recommendation rows |
| Server-state cache | Browser | Fetched data with its age: offers, reviews, search results; dedupes and refetches on focus |
| Cart client | Browser | Cart count and mini cart; optimistic updates; talks to the cart service through the BFF |
| Checkout client | Browser | Section state of the checkout session; idempotent Place order; 3DS handling |
| Payment SDK | Provider iframes | Card fields, wallets, 3DS challenge; returns tokens |
| Telemetry and experiments | Browser and edge | Web 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.
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.
| Entity | Where it lives in the client | Freshness | Notes |
|---|---|---|---|
| Search state (query, filters, sort, page) | The URL | Always current: it is the state | Normalized by one function; parsed on the server and the client the same way |
| Search results page | Server-state cache, keyed by the normalized URL | Minutes | Results plus facet counts plus total; replaced as a whole when the URL changes |
| Product (family) and SKUs | Server HTML and an embedded JSON payload | Minutes | Includes the variant matrix with stock buckets |
| Offer (price, stock, seller, delivery) | Server-state cache, keyed by SKU and region | 30 to 60 s, refetched on tab focus | Wins over the matrix when they disagree |
| Reviews | Server-state cache, paged | Minutes | Summary is in the HTML; the list loads lazily |
| Cart | Server; client keeps a cached copy and the count | Live; re-priced on every view | Lines point to offer IDs |
| Checkout session | Server; its ID is in the URL | Live | Survives refresh and 3DS redirects |
| Recently viewed | Server for accounts; localStorage for guests, capped at 50 IDs | Instant | IDs only; details fetched when shown |
| Experiment assignments | Cookie set at the edge | Stable for the experiment's life | Read on the server for rendering, on the client for exposure logging |
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 };
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.
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"
}
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" }
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 }
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
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
};
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.
fetchpriority="high", the browser fetches it while the JavaScript is still loading. Illustrative numbers.Budgets per page type
| Page | LCP target (p75, phone) | JavaScript budget (compressed) | Main risks |
|---|---|---|---|
| Product page | 2.0 s | about 150 KB for the first view | Hero image found late, a carousel that hydrates first, third-party tags |
| Listing | 2.2 s | about 170 KB (filters are interactive) | Too many product images marked eager, facet panel re-rendering the whole grid |
| Home | 2.2 s | about 150 KB | Hero banner chosen by a client-side experiment, personal rows shifting layout |
| Checkout | 2.5 s | about 120 KB plus the payment SDK | Payment 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 (
startTransitionin React) so it can't block the next paint. - Keep handlers small. Analytics calls in a click handler should be queued (for example with
requestIdleCallbackor after asetTimeout), 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
fieldsetwith alegend; 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 theAccept-Languageheader, so each version is crawlable and cacheable.hreflanglinks 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.NumberFormatwithstyle: "currency"gets symbols, separators, and decimals right per locale (1.234,56 € in Germany, ¥1,235 in Japan with no decimals). Dates for delivery useIntl.DateTimeFormat, and "order within 2 hours" usesIntl.RelativeTimeFormat. - Right-to-left layouts. Arabic pages set
dir="rtl", and CSS uses logical properties (margin-inline-start, notmargin-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.
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"
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.
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
- 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, andSameSite=Lax; state-changing requests (add to cart, place order) also carry a CSRF token or check theOriginheader.
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:
- 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.
- 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.
- 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.
- 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.
- 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.
| Minutes | What you do | What to draw or say |
|---|---|---|
| 0 to 6 | Scope questions and assumptions | The shopper's path as five boxes: home, search, product, cart, checkout. Write the numbers you assume next to it. |
| 6 to 14 | Rendering per page type | A table of page types with rendering, cache lifetime, and personal holes. Say the cookie trap out loud. |
| 14 to 24 | Architecture and data flow | Edge, CDN, render server and BFF, services; the browser islands. One product page request traced end to end. |
| 24 to 36 | First deep dive: listing or product page | URL as state with facet counts, or hero LCP plus variant matrix plus fresh offer. |
| 36 to 48 | Second deep dive: freshness and checkout | Cache layers with lifetimes; idempotent Place order; the 3DS redirect. |
| 48 to 55 | Cross-cutting: performance, a11y, i18n, security | Budgets per page type, no tags on checkout, CSP, bot layers. |
| 55 to 60 | Follow-ups and what you'd do next | Experiments 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?"
First, make sure the traffic hits the cache: warm the CDN for the deal's product and category pages before 9:00, lengthen stale-while-revalidate so a slow origin never blocks a page, and make sure the deal start purges by tag rather than relying on a short lifetime alone.
Second, protect the money path: put the deal's add-to-cart behind a waiting room if demand is far above stock, score bots at the edge, and enforce per-customer limits on the server. Third, freeze risky changes: no new experiments or deploys on checkout that week, and dashboards on checkout completion and error rate by minute.
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?"
It's a personal value, so it can't go into the shared shell. Render a fixed-height placeholder in the shell and fill it from a private, no-store endpoint, or as a streamed per-request hole if it must be in the first paint.
The placeholder's height must be reserved even when the answer is "never bought", or the page shifts for the 99 percent of shoppers who haven't bought it. Often the better design is a small line near the Add to cart button rather than a banner at the top.
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?"
Crawlers don't scroll. Products past the first batch are no longer linked from any page a crawler can load, so tail products lose their internal links and get crawled less.
Keep the scroll experience but back it with paged URLs: a load-more button that updates ?page=N, a server that can render each page, and plain links to the pages. Each paged URL is its own canonical.
Think first
Follow-up: "Support reports a handful of double charges a week. Where do you look?"
Check that the idempotency key is created once per attempt and survives what can happen in between: a 3DS redirect, a refresh, a second tab. If the key lives only in memory, a refresh after a timeout creates a new one. Store it with the checkout session on the server, or in sessionStorage keyed by the session ID.
Then check every retry path: a fetch wrapper or service worker that retries POST requests with a fresh key, or a second button (a sticky mobile bar) that submits a separate request. Logging the key with every payment attempt makes these easy to find.
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.
References
- Rendering on the Web Server, static, and client rendering trade-offs.
- Optimize Largest Contentful Paint Breaks LCP into parts and shows how to fix each one, including the hero image.
- Milliseconds make millions (web.dev) The Deloitte study on mobile speed and retail conversion, with its method.
- Cache-Control (MDN) max-age, s-maxage, stale-while-revalidate, private, and no-store.
- Product structured data (Google Search Central) Which Product and Offer fields search engines use for rich results.
- Managing crawling of faceted navigation URLs Google's guidance on filter URLs: what to block, what to keep crawlable, and how.
- Speculation Rules API (MDN) Prefetching and prerendering likely next pages.
- Strict CSP (web.dev) Nonce-based policies with strict-dynamic.
- RFC 9457: Problem Details for HTTP APIs The standard error format used by the checkout API.
- Idempotent requests (Stripe API docs) How a payment API uses idempotency keys to make retries safe.
Concepts behind it
- Choosing a rendering strategyHow to pick the rendering strategy for each page by asking who sees it, how often it changes, how interactive it is, and whether strangers must find it, then mixing strategies route by route in one app instead of choosing one for everything.
- The URL as stateFilters, sort, page, tab, and selected item stored in the address bar instead of only in memory, so a view can be shared, bookmarked, refreshed, and walked back with the Back button: the cheapest state store the browser gives you.
- Images and mediaSending each user an image of the right size and format, reserving its space, and loading it at the right moment: images are usually the largest share of a page's bytes and its LCP element.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- Content Security PolicyA response header that tells the browser which scripts, frames, and connections a page may use, so an injected script is blocked even when an XSS bug slips through.
- A/B testingShowing different versions of a page to randomly split groups of users and measuring which works better: stable bucketing, no flicker, honest statistics, and guardrails that catch harm.
- Locales, routing, and SEOHow a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.