cf.completefrontend
My progress
SYSTEM DESIGN · #55

Design an e-commerce cart and checkout

A cart that works for guests and signed-in shoppers, stays the same across tabs and devices, and feeds a multi-step checkout that takes payment exactly once. The hard parts are deciding who owns the cart, merging carts at sign-in, quantity changes that race each other, prices and stock that change under the shopper, and a payment step that must never charge twice.

The question

Design the cart and checkout for the website of a large online store. Shoppers add products from product pages and search results, change quantities, and apply promo codes. They can shop as guests or sign in, and they often switch between their phone and laptop before buying.

Checkout collects an address, a shipping method, and a payment, then places the order. Walk me through the client: where the cart lives, how it stays correct, and how you make sure nobody is ever charged twice.

Real-life examples: Amazon cart and checkout, Shopify checkout, Etsy cart, Zalando checkout, Target cart and checkout

Requirements: what to ask before drawing anything

A cart looks like a list with a total at the bottom. In an interview it's a small distributed system: a list that lives on the server, is edited from many tabs and devices, holds prices the shopper doesn't control, and ends in a payment that moves real money. You have about 45 minutes. Spend the first five asking the questions whose answers change the design, and say out loud what you assume when the interviewer shrugs.

Ask about who shops (guests or accounts), where they shop (one device or several), what the cart can hold, who sets prices and stock, how payment works, and which countries you sell in. The table gives the answer this guide assumes for each one, and which part of the design moves if the answer is different.

QuestionAnswer we assumeWhat it changes
Can people buy without an account?Yes. About half of orders are guest orders.The cart needs an owner before sign-in, and a merge at sign-in.
Do shoppers switch devices?Often: browse on the phone, buy on the laptop.The cart must live on the server, not only in the browser.
How big is a cart?Usually 1 to 5 lines. We support up to 100 lines, and up to 10 of one item.Small enough to send the whole cart in every response.
Who sets prices, stock, and discounts?Back-end services. Prices can change during a sale; stock runs out.The client shows numbers; the server decides them.
How do people pay?Cards through a payment provider, wallets (Apple Pay, Google Pay), and PayPal.Hosted card fields, a redirect flow, and 3-D Secure.
Which countries?12 countries, 8 currencies, prices shown with or without tax by country.Money formatting, tax display, and address forms per country.
How busy does it get?2 million visits a day, 10 times that on sale days.Add to cart must be cheap; checkout must survive retries.
The three surfaces of the design. A product page for trail shoes at 119 dollars, size 42, with an Add to cart button. An arrow to the cart drawer, which says Added to your cart and lists shoes times 1 and socks times 2, a subtotal of 143 dollars, and a Checkout button. An arrow to checkout, with four steps: 1 Address and 2 Shipping done, 3 Payment current, and 4 Review.ProductTrail shoes$119, size 42Add to cartDrawerAdded to cartShoes x 1Socks x 2Subtotal $143CheckoutCheckout1 Address2 Shipping3 Payment4 Reviewinstant feedbackproof, quick editsteps, pay once
Each surface brings its own hard part: the add button must answer instantly, the drawer must show proof and allow a quick fix, and checkout is a flow of steps that must take payment exactly once.

Functional requirements

  • Add a product variant (a specific size and color, called a SKU, stock keeping unit) to the cart from a product page or a search result.
  • See the cart in a side drawer right after adding, and on a full cart page: lines, quantities, prices, discounts, and an estimated total.
  • Change quantities, remove lines, save lines for later, and apply or remove a promo code.
  • Keep one cart per shopper across tabs and devices. A guest cart survives a browser restart and joins the account cart at sign-in.
  • Check out in steps: shipping address, shipping method, payment, review. Progress survives a reload.
  • Pay by card (with 3-D Secure when the bank asks), by wallet, or by PayPal, and place the order exactly once.
  • Show a confirmation page with the order number that is safe to reload and to bookmark.

Non-functional requirements, with numbers

Non-functional requirements are the qualities the feature must have. Give each one a number, measured at the 75th percentile of real visits, because a number can be checked and the word "fast" can't.

  • Instant feedback. Pressing "Add to cart" updates the page within 100 ms, well inside the 200 ms that INP counts as good. INP, Interaction to Next Paint, is the Core Web Vital for how fast a page answers taps and clicks.
  • Correct money. The amount charged always equals the total on the review screen the shopper confirmed. If a price changes, the shopper sees and accepts it before paying.
  • Exactly-once orders. A double click, a retry after a timeout, or a reload during payment never creates two orders or two charges.
  • Small checkout scope for card data. Card numbers never touch our servers or our JavaScript, which keeps the store in the smallest PCI DSS compliance scope (PCI DSS is the card industry's security standard).
  • Fast checkout load. Checkout's largest content paints within 2.5 s (a good LCP, Largest Contentful Paint) on a mid-range phone over 4G, while carrying the payment provider's script.
  • Accessible. Every step works with a keyboard and a screen reader, meets WCAG 2.2 AA, and announces cart changes and errors.

Out of scope, and why

  • The product page and search: they only need an "add to cart" call and a cart count.
  • Tax and shipping rate engines: they're back-end services. The client asks for a quote and shows it.
  • Fraud scoring, inventory reservation, and order fulfilment: server work. We only handle the answers they send back.
  • Subscriptions and split shipments: worth one sentence if asked, but they don't change the core flow.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design the most (select all that apply)? Select all that apply.

What makes a cart and checkout hard

Follow one shopper for a week. On Monday, Priya adds trail shoes and two pairs of socks on her phone, as a guest. On Wednesday she signs in on her laptop, where her account cart already holds a desk lamp from last month.

On Thursday the shoes drop from $140 to $119 and the lamp sells out. On Friday she checks out, her bank asks her to approve the payment in its app, and her train goes into a tunnel just as she taps "Place order". Every chapter of this guide exists because of something in that week.

One shopper's week, top to bottom. Mon: Phone, as a guest: adds shoes and 2 socks, which needs a guest cart, owned by the server. Wed: Laptop, signs in: account cart has a lamp, which needs two carts become one: the merge. Thu: Shoes drop to $119, the lamp sells out, which needs prices and stock change while she waits. Fri: Checkout: address, shipping, card, bank check, which needs a multi-step flow and 3-D Secure. Fri: Taps Place order as the train enters a tunnel, which needs unknown outcome: never charge twice.MonPhone, as a guest: adds shoes and 2 socksa guest cart, owned by the serverWedLaptop, signs in: account cart has a lamptwo carts become one: the mergeThuShoes drop to $119, the lamp sells outprices and stock change while she waitsFriCheckout: address, shipping, card, bank checka multi-step flow and 3-D SecureFriTaps Place order as the train enters a tunnelunknown outcome: never charge twice
Five ordinary moments, five design problems. The last one is the most dangerous: the client doesn't know whether the card was charged.

Five properties make this problem harder than it looks. Name them early in the interview, because each one rules out a naive design.

  1. Many writers, one cart. The same cart is changed from two tabs, a phone, a laptop, the sign-in merge, and the server itself when prices and stock move. A cart that only one screen edits is easy; this one is shared state.
  2. The money isn't ours to compute. Prices, discounts, tax, and shipping come from back-end services and change on their own schedule. The browser can show a number, but it can never be the one that decides what is charged.
  3. Fast clicks meet slow networks. Shoppers press + four times in a second. A phone on a train has 300 ms or more of round trip, and replies can arrive out of order. The page must feel instant and still end up correct.
  4. Checkout is a long, stateful flow. Four steps, each with its own form and server calls, that must survive a reload, a back button, a sign-in in the middle, and a trip to a bank page or PayPal and back.
  5. Payment can't be undone, and its outcome can be unknown. If the connection drops after "Place order", the charge may or may not have happened. A naive retry charges twice; a naive error message makes the shopper pay again by hand.

There's also a business shape to it. A cart is the top of a funnel: product page, cart, each checkout step, order. The Baymard Institute puts the average share of abandoned carts at about 70%, and many of the reasons shoppers give are design problems: forced account creation, a long checkout, surprise costs at the end, form errors. A design that is correct but makes the shopper wait, retype, or guess loses money every day.

PropertyWhat it forcesWhere we handle it
Many writersA server-owned cart, a version number, a merge rule, tab and device syncWhere the cart lives, Merging carts, Tabs and devices
Money is the server'sServer pricing on every read, a final check before paying, integer centsPrices, stock, and promotions
Fast clicks, slow networkOptimistic quantities, one request in flight per line, dropping stale repliesQuantity changes that race
Long stateful flowA state machine, a server checkout session, a URL per stepCheckout as a state machine
Irreversible paymentHosted card fields, idempotency keys, a status to poll when the outcome is unknownPayment, Placing the order

Think first

Rank these failures from worst to least bad: the cart badge shows 2 for a second when the cart has 3; a sold-out lamp is silently removed; the shopper is charged twice.

Check your understanding · Exercise 2

Which of these are real reasons a cart is harder than a simple list (select all that apply)? Select all that apply.

Where the cart lives: server, browser, or both

The first decision shapes everything after it: who owns the cart. The naive answer is the browser. Put the lines in localStorage, render the cart from there, and send the whole list to the server at checkout.

It's fast, works offline, and needs no database. It also breaks on Priya's Wednesday: her laptop has never seen her phone's localStorage, so the shoes and socks are simply not there.

It breaks in quieter ways too. The browser holds Monday's price, so every page must re-price the cart. Two tabs write the same key and the last one wins. The store can't send a "you left something in your cart" email. And the merge at sign-in runs in a tab that can close halfway.

The opposite naive answer, a server cart only for signed-in users, forces guests to make an account before adding a single item, one of the top reasons shoppers leave.

Decision Where should the cart, including a guest's cart, live?

Browser only (localStorage)

The whole cart is a JSON list in localStorage. The server sees it only when checkout starts.

Pros

  • No server storage, no request on add to cart.
  • Works offline.

Cons

  • Doesn't follow the shopper to another device.
  • Prices and stock go stale and must be re-checked on every page.
  • No abandoned-cart emails, no cart analytics on the server.
  • Merging at sign-in runs in a tab that can close at any time.

Guest in the browser, account on the server

Guests keep a localStorage cart. At sign-in the client uploads it and the server merges it into the account cart.

Pros

  • Signed-in carts follow the shopper.
  • Guests cost the server nothing until they sign in.

Cons

  • Two code paths for every cart action, so every bug exists twice.
  • The upload at sign-in can fail or run twice, and the client must handle both.
  • Guest carts are still unpriced and invisible to the server.

Server cart for everyone, guest id in a cookie

The first add to cart creates a server cart. A guest's cart id goes in an HttpOnly cookie; a signed-in cart is found through the session. The browser keeps only a copy of the last answer.

Pros

  • One code path for guests and accounts.
  • The server prices every read, so totals are never stale.
  • The merge runs on the server in one transaction.
  • Abandoned-cart emails and analytics work for everyone who leaves an email.

Cons

  • Every add to cart is a network request.
  • The store pays to keep guest carts, many of them abandoned.
  • Needs a cookie, so it needs care with consent rules and bots.

What we pick: Server cart for everyone. The cross-device requirement and the rule that the server decides money both point there, and one code path halves the bugs. The two costs are manageable: the add-to-cart request is hidden behind an optimistic update, and guest carts are created only on the first add (never on a page view) and expire after 30 days without activity. The browser keeps a small copy for speed, never the truth.

Where cart data lives. In the browser: the cart_id cookie, HttpOnly for 30 days, which names a guest cart; a localStorage snapshot of count and version, for the badge only; and the cart store in memory, which drives the page and writes the snapshot. On the server: the Cart API, which finds the cart by cookie or session and answers with the whole cart and its version, and the Cart DB, the truth.Browsercart_id cookieHttpOnly, 30 dayslocalStoragecount + version, badge onlycart store (memory)confirmed + pendingwrites countServerCart API (BFF)cookie or sessionCart DBthe truthwhole cart
The cookie only names a guest's cart, and the snapshot only paints the badge on CDN-cached pages before GET /api/cart answers. Prices and lines live in the database, and the page shows the server's copy.

Four places, one job each

  • The cart database is the truth: lines, quantities, the price at the time each line was added, and a version number that goes up by one on every change.
  • The cart_id cookie names a guest's cart. Set it with HttpOnly so injected scripts can't read it, Secure so it only travels over HTTPS, SameSite=Lax so another site can't post to the cart with it, and Max-Age=2592000 (30 days). The id is 128 random bits, because anyone holding it holds the cart. As a strictly necessary cookie, it usually needs no consent, but check with your privacy team.
  • The cart store in memory drives the page: the last cart the server confirmed, plus any changes the shopper made that the server hasn't confirmed yet (chapter 5).
  • A snapshot in localStorage holds only the item count, the cart version, and an owner tag. It never holds prices or lines.

Why keep a snapshot at all? Because product and search pages are cached at a CDN (a content delivery network: servers near the shopper that keep copies of pages), so their HTML is the same for everyone and can't contain this shopper's cart count.

Without the snapshot, the badge pops in half a second late. With it, the header paints "3" on the first frame, and GET /api/cart confirms it in the background. Reserve the badge's width in CSS either way, so a late number never shifts the layout.

ts
type Snapshot = { count: number; version: number; owner: string };

export function readSnapshot(owner: string): Snapshot | null {
  try {
    const raw = localStorage.getItem("cart:snapshot");
    const snap = raw ? (JSON.parse(raw) as Snapshot) : null;
    // A snapshot from another person on a shared computer is worse than none.
    return snap && snap.owner === owner ? snap : null;
  } catch {
    return null; // storage blocked or corrupt: just wait for the server
  }
}

export function writeSnapshot(cart: { version: number; count: number }, owner: string) {
  const current = readSnapshot(owner);
  if (current && current.version >= cart.version) return; // never go backwards
  try {
    localStorage.setItem("cart:snapshot", JSON.stringify({ ...cart, owner }));
  } catch {}
}
The owner is "guest" or a hash of the user id, never the raw id or an email. A version check stops an older tab from overwriting a newer count.

Check your understanding · Exercise 3

With a server cart for everyone and a localStorage snapshot, which statements are true (select all that apply)? Select all that apply.

Merging a guest cart at sign-in

On Wednesday Priya signs in on her laptop, where her account cart holds a desk lamp from last month. The shoes and socks are still in her phone's guest cart. When she signs in on the phone too, that guest cart meets the account cart: two carts now belong to one person, and the design must say exactly what the result is.

Where the merge runs matters as much as the rule. Run it on the server, inside the sign-in request, in one database transaction: read the guest cart named by the cookie, read the account cart, write the merged lines, mark the guest cart as merged, and answer with the new session cookie and an expired cart_id cookie.

If the merge ran in the browser after sign-in, a closed tab or a dropped request halfway through would leave two half-merged carts, and the next device to load would see a mess.

The merge at sign-in, as a sequence between the Browser, the sign-in BFF, and the Cart DB. The browser posts the login with its cart_id cookie for guest cart g7. The BFF locks guest cart g7 and reads the account cart. The DB answers: guest has shoes and socks, account has a lamp. The BFF writes the merged lines and marks g7 merged. The BFF answers the browser with a session cookie and an expired cart_id cookie, then the merged cart with two notices.BrowserSign-in BFFCart DBPOST /login, cart_id=g7lock g7, read account cartboth cartswrite lines, mark g7session, cart_id gonemerged cart + 2 notices
One request, one transaction. Locking the guest cart and setting mergedInto make a retried or parallel sign-in harmless: the second merge finds the marker and changes nothing.

Decision When the same product is in both carts, what quantity should the merged cart hold?

Add them up

Guest 3 socks plus account 2 socks gives 5.

Pros

  • Never loses a unit the shopper chose.
  • Matches intent for things people buy more of over time, like groceries.

Cons

  • Turns one pair of shoes added twice (on two devices) into two pairs.
  • Not idempotent: a merge that runs twice adds the guest cart twice.

Keep the larger

Guest 3 and account 2 gives 3. Lines that are only in one cart are kept as they are.

Pros

  • Treats the same product in both carts as the same intent recorded twice, which it usually is.
  • Running it twice gives the same result.

Cons

  • Can undercount when the shopper really wanted both amounts. Rare, and visible in the cart before paying.

Guest cart replaces the account cart

The cart from this browser wins. The account cart's lines are dropped.

Pros

  • Simple, and reflects what the shopper was doing a minute ago.

Cons

  • Silently deletes lines the shopper added on another device, maybe on purpose.

Account cart wins, guest cart ignored

The guest cart is thrown away at sign-in.

Pros

  • Simplest of all.

Cons

  • The shopper signs in and the items they just added disappear. This is the version that generates support tickets.

What we pick: Keep every line from both carts, and for a product in both, keep the larger quantity. It never drops anything the shopper chose, it doesn't double the common case of the same item added on two devices, and it's naturally idempotent. A grocery store might choose "add them up", which is fine as long as the merge is guarded by a merged marker so it can't run twice. Whatever the rule, cap the result and tell the shopper what happened.

Merge rule
Guest cart Wool socks: 3 Trail shoes: 1 Coffee mug: 2 Desk lamp: 0

Merged with keep larger: subtotal 191 dollars. Nothing lost, nothing doubled. The sold-out lamp stays visible but doesn't count in the subtotal.

Merging with the rule keep larger. Wool socks: guest 3, account 2, merged 3. Trail shoes: guest 1, account 1, merged 1. Coffee mug: guest 2, account 0, merged 2. Desk lamp: guest 0, account 1, merged 1, sold out. Subtotal 191 dollars.guestaccountmergedWool socksstock 40, max 10323Trail shoesstock 1, max 2111Coffee mugstock 50, max 10202Desk lampstock 0011sold outrule: keep largermerge runs: 1subtotal: $191lost 0, doubled 0, capped 0
Nothing lost, nothing doubled. The sold-out lamp stays visible but doesn't count in the subtotal.

The edge cases that decide whether the merge is trusted

  • Same product, different variant. Size 42 and size 43 are different SKUs, so they are different lines. Merge by SKU, never by product name.
  • Personalized lines. A mug engraved "Anna" and the same mug engraved "Ben" share a SKU but are different things. Lines with custom options are never merged; both are kept.
  • Limits. Cap each merged line at stock and at the product's maxPerOrder. If the two carts together pass the 100-line limit, keep the most recently changed lines and move the rest to "Saved for later" instead of deleting them.
  • Sold-out lines are kept and marked, so the shopper sees them and decides. They don't count in the subtotal.
  • Promo codes. If both carts carry a code and the store allows only one, the server re-checks both against the merged cart and keeps the one that is valid and saves more, then says which one it kept.
  • Empty carts. If the account cart is empty, don't copy lines: just attach the guest cart to the user. If the guest cart is empty, there is nothing to do and nothing to announce.
  • Retries and races. A retried sign-in, or two tabs signing in at once, must not merge twice. Lock the guest cart row in the transaction and set mergedInto on it; a merge that finds the marker returns the account cart unchanged.
  • Sign-in in the middle of checkout. The account cart may hold extra lines, like the lamp. Never carry them into a payment silently: send the shopper back to the review step with a notice, because the total they were about to approve has changed.
ts
// Server side, inside the sign-in transaction.
function mergeCarts(guest: Cart, account: Cart, catalog: Catalog): MergeResult {
  if (guest.mergedInto) return { cart: account, notices: [] }; // already done
  const lines = new Map(account.lines.map((l) => [lineKey(l), l]));
  const notices: Notice[] = [];
  for (const g of guest.lines) {
    const key = lineKey(g); // sku, plus custom options if any
    const a = lines.get(key);
    const wanted = a ? Math.max(a.quantity, g.quantity) : g.quantity;
    const { stock, maxPerOrder } = catalog.get(g.sku);
    const quantity = Math.min(wanted, maxPerOrder, Math.max(stock, 1));
    if (quantity < wanted) notices.push({ kind: "capped", sku: g.sku, wanted, quantity });
    lines.set(key, { ...(a ?? g), quantity, updatedAt: latest(a, g) });
  }
  guest.mergedInto = account.id;
  return { cart: { ...account, lines: [...lines.values()], version: account.version + 1 }, notices };
}
A sold-out line keeps quantity 1 and is marked out of stock on read, rather than vanishing. The 100-line cap and promo codes are left out to keep the sketch short.

The client's part is small but visible. The sign-in response carries the merged cart and its notices; show them once, in plain words: "We added 2 items from your last visit", "Trail shoes: only 1 left, so your cart has 1". Then broadcast an auth change to other tabs (chapter 6) so none keeps showing the guest cart.

Check your understanding · Exercise 4

Priya is on the payment step as a guest with $143 of items (shoes at $119, two pairs of socks at $12). She signs in to use a saved card, and her account cart holds a $45 lamp. What should happen (select all that apply)? Select all that apply.

Quantity changes that race each other

Priya wants three pairs of socks, then changes her mind to two. She taps + twice and - once in under a second. The naive stepper waits for the server after each tap: it shows a spinner, sends PUT, and updates the number when the reply comes. On a phone with a 300 ms round trip, that's almost a second of a stepper that ignores her, and many shoppers tap again because nothing seemed to happen.

The next naive fix updates the number at once and sends one "+1" or "-1" request per tap. Now three requests race, and replies don't have to arrive in order: one can hit a slower server, one can be retried after a timeout.

If the reply to the second tap (quantity 3) lands last, the stepper jumps back to 3 although she wanted 2. Worse, if the client retries a "+1" that the server had already applied before the connection dropped, she gets a pair she never asked for.

Decision How should the quantity stepper talk to the server?

Wait for the server on every tap

Disable the stepper, send the change, re-enable it when the reply arrives.

Pros

  • The number on screen is always one the server confirmed.
  • No races: one request at a time.

Cons

  • Every tap costs a round trip, so three taps take a second on mobile.
  • Feels broken, and disabled buttons lose keyboard focus.

Optimistic, one delta request per tap

Update the number at once, send { change: +1 } for every tap.

Pros

  • Instant feedback.
  • Simple to write.

Cons

  • Out-of-order replies make the number jump.
  • A retried delta changes the cart twice.
  • Three taps cost three requests and three re-pricings.

Optimistic, absolute quantity, one request in flight per line

Update the number at once. After a short pause, send { quantity: 2 }, the latest wanted value. While a request is in flight, later taps only update the wanted value; when the reply lands, send again only if it changed. Accept a reply only if its cart version is newer.

Pros

  • Instant feedback and no flicker.
  • Retries are safe: setting 2 twice is still 2.
  • Many taps become one or two requests.

Cons

  • More code: a small queue per line and a version check.
  • The line total is briefly unconfirmed.

What we pick: Optimistic, absolute, one request in flight per line. Sending the absolute value makes every request safe to repeat, the version check throws away stale replies, and coalescing taps cuts requests. The extra code is small and shared by the stepper, the remove button, and "Save for later".

Stepper
Network

Priya taps + three times and - once. She wants 3.

  1. 0, 100, 200 ms: three +1 requests; the stepper shows 2, 3, 4
  2. 300 ms: reply 2; 400 ms: reply 3
  3. 550 ms: - from the 3 on screen shows 2 and sends -1
  4. 850 ms: reply 3
  5. 1100 ms: the slow reply for the third tap says 4

Screen 4, server 3. Wrong: the slow reply lands last, so the screen ends on 4 while the server holds 3. The cart page and checkout will contradict it.

Strategy: delta, network: slow reply. The stepper showed 1, 2, 3, 4, 2, 3, 2, 3, 4. After it settles at 1100 ms, the screen shows 4 and the server holds 3, after 4 requests.what the stepper showed, in order123423234after it settlesscreen 4server 34 requests1100 ms
Wrong: the slow reply lands last, so the screen ends on 4 while the server holds 3. The cart page and checkout will contradict it.

What the page shows: confirmed plus pending

The cart store keeps two things apart: the confirmed cart, which is the last full cart the server returned, with its version, and a map of pending changes, one per line, holding the quantity the shopper wants. The page always renders the confirmed cart with the pending quantities laid on top. A reply never overwrites what the shopper is still changing, because the pending value sits above it until the request that carries it is answered.

ts
function setQuantity(lineId: string, wanted: number) {
  const change = pending.get(lineId) ?? { wanted, inFlight: false, timer: 0 };
  change.wanted = wanted;
  pending.set(lineId, change);
  render(); // the stepper shows the new number this frame
  clearTimeout(change.timer);
  change.timer = window.setTimeout(() => flush(lineId), 250);
}

async function flush(lineId: string) {
  const change = pending.get(lineId);
  if (!change || change.inFlight) return; // the reply handler calls us again
  const sent = change.wanted;
  change.inFlight = true;
  try {
    acceptCart(await api.put(`/api/cart/items/${lineId}`, { quantity: sent }));
    if (change.wanted === sent) pending.delete(lineId);
  } catch {
    pending.delete(lineId); // back to the confirmed quantity
    announce("Couldn't update the quantity. Please try again.");
  } finally {
    change.inFlight = false;
    if (pending.get(lineId) === change) flush(lineId); // she kept tapping
    render();
  }
}
acceptCart keeps the incoming cart only if its version is higher than the one held, so a slow reply can't undo a newer one.
  • Roll back to the confirmed value, not the previous local one. After three taps, "the value before the last tap" is a guess the server never saw. The confirmed cart is the only number both sides agree on. A timeout can first be retried with the latest wanted value, since absolute quantities are safe to resend; roll back only when the retries give up.
  • Be optimistic about quantity, not money. Grey the line total and the subtotal while a line is pending, and fill them in from the reply. A "3 for 2" offer or a tax rule can make the real total different from quantity times price.
  • Let the server correct you. If she asks for 5 and only 3 are left, the server applies 3 and returns a notice. The stepper settles on 3 and a message under the line explains why.
  • Don't disable the stepper while a request is in flight. Disabled buttons drop focus and ignore keyboard users. Keep them enabled and let the queue absorb the taps; disable + only at the line's limit.
  • Removing a line is a quantity of 0, with an "Undo" link for a few seconds that sets it back. Undo is friendlier than a confirmation dialog, and it uses the same request.

Add to cart, exactly once

Adding a new product is different: the line doesn't exist yet, so there's no line id to set an absolute quantity on. The client creates an idempotency key (a random id, from crypto.randomUUID()) for each press of the button and sends it in an Idempotency-Key header with POST /api/cart/items.

If the request times out and the client retries with the same key, the server recognizes the key and returns the cart it already produced instead of adding a second unit. A new press gets a new key, so a shopper who really wants two can press twice.

On screen, the press bumps the header count and opens the drawer with what the page already knows (name, image, size, price), marked as adding. The confirmed line replaces it when the reply arrives, usually a few hundred milliseconds later. If the server refuses (sold out a second ago), the drawer says so and the count goes back.

Check your understanding · Exercise 5

Socks are confirmed at quantity 1 (cart version 7). Priya taps + three times, so the stepper shows 4 and one request with quantity 4 is sent. Before it returns, she taps - once. Then the request fails. What should the stepper show (select all that apply)? Pick one.

Keeping tabs and devices in step

Shoppers open many tabs: one product per tab, then a cart tab to compare. Every tab in the same browser sends the same cookie, so they all read the same server cart. The problem is that they don't know when it changed.

Priya removes the socks in tab A; tab B still shows "3" in the header and an old cart in its drawer until something makes it ask again. Across devices it's the same problem with a longer gap: the laptop doesn't know what the phone did an hour ago.

A stale tab is not a money bug, because the server prices every request and checks everything again at checkout. It's a trust bug: the drawer says three items, the cart page says two. So the goal is modest: tabs in one browser agree within a fraction of a second, and another device is right whenever the shopper looks at it.

Decision How should a tab tell the other tabs in the same browser that the cart changed?

The storage event

After each change, write the snapshot to localStorage. Every other same-origin tab gets a storage event with the key and its new value.

Pros

  • Works in every browser, for many years.
  • Free here, because we already write the snapshot.

Cons

  • Carries only strings, and only when the value actually changes.
  • Mixes a message channel with a storage key; an auth change needs a fake key to signal it.
  • Does nothing if storage is blocked or full.

BroadcastChannel

Every tab opens new BroadcastChannel("cart"). After a confirmed change, a tab posts { type: "cart", cart }; all other same-origin tabs and workers receive it.

Pros

  • Made for this: typed messages, structured clone, no storage involved.
  • Can carry the whole cart (a few KB), so other tabs don't even need a request.
  • Supported in all current browsers (Safari since 15.4).

Cons

  • Only reaches tabs in the same browser profile, like the storage event.
  • A tab that was asleep or frozen can miss messages, so it still needs a check when it wakes up.

Only refetch when a tab becomes visible

On visibilitychange (to visible) and on focus, call GET /api/cart with the version as If-None-Match.

Pros

  • No channel at all, and it also covers other devices.
  • Cheap: an unchanged cart answers 304 Not Modified in about 200 bytes.

Cons

  • Two tabs side by side on a wide screen are both visible, so neither refetches.
  • One request every time the shopper switches tabs.

What we pick: Use BroadcastChannel for messages between tabs, and keep the refetch on becoming visible as the safety net for missed messages and for other devices. The storage event is a fine fallback for very old browsers, but in 2026 the channel is available everywhere we care about, and it keeps cart messages and auth messages out of storage.

Syncing the cart. On the laptop, Tab A removes the socks: it sends PUT to the Cart API, now at version 9, and posts the new cart on the BroadcastChannel to Tab B, whose badge goes from 3 to 1. The phone sends GET with If-None-Match when it becomes visible, and gets a 304 if nothing changed or the new cart if it did.Laptop browserTab Asocks removedversion 9Tab Bbadge: 1was 3syncPhoneShoes x 1when visibleCart APIversion 9PUTGET on visible304 if sametabs: the channeldevices: on visible
Tabs in one browser hear about a change through BroadcastChannel at once. Another device asks the server when it comes back into view, with the version as If-None-Match, so an unchanged cart costs a tiny 304.
ts
const channel = new BroadcastChannel("cart");

// Called whenever this tab gets a cart from the server.
function acceptCart(cart: Cart, fromOtherTab = false) {
  if (store.confirmed && cart.version <= store.confirmed.version) return;
  store.confirmed = cart;
  writeSnapshot({ version: cart.version, count: countItems(cart) }, owner());
  if (!fromOtherTab) channel.postMessage({ type: "cart", cart });
  render();
}

channel.onmessage = ({ data }) => {
  if (data.type === "cart") acceptCart(data.cart, true);
  if (data.type === "auth") location.reload(); // someone signed in or out
};

document.addEventListener("visibilitychange", () => {
  if (document.visibilityState === "visible") refetchCart(); // sends If-None-Match
});
The version check makes every path safe to repeat: a cart that arrives twice, or late, changes nothing.

Other devices: ask at the right moments

Pushing cart changes to every device over a WebSocket would work, but a cart changes a few times per session, and a store with 2 million visits a day would hold tens of thousands of open connections to deliver almost nothing. Instead, the client asks when the shopper is about to look: a tab becomes visible or focused, the drawer or cart page opens, checkout starts. Each ask sends the cart version as If-None-Match; most answers are a bodiless 304.

MomentWhat the client doesTypical cost
Another tab confirms a changeApplies the cart from the channel messageNo request
Tab becomes visible or focusedGET /api/cart with If-None-MatchAbout 200 bytes when unchanged
Drawer or cart page opensSame conditional request, then shows the answerOne round trip, usually a 304
Checkout startsServer re-prices and pins the cart versionOne request that the shopper waits for

Two devices can still change the same line at the same time. With absolute quantities, the last write wins, and that's acceptable: it's one person, and they see the result the next time they look.

What must not happen is a change sneaking into a checkout already in progress on the other device. Checkout pins the cart version it reviewed; if the cart changes before the order is placed, placing the order fails with a conflict and the shopper goes back to review (chapter 11).

Check your understanding · Exercise 6

Which of these situations does tab and device sync, on its own, handle correctly (select all that apply)? Select all that apply.

Prices, stock, and promotions: the client shows, the server decides

On Monday the shoes cost $140. On Thursday a sale drops them to $119, and the last desk lamp sells to someone else. On Friday Priya opens her cart.

The naive cart either shows Monday's prices (because it saved them with the line) or adds up prices that the client loaded with the product page. Both are wrong in a way that matters: either she sees a total that checkout will contradict, or the client is computing a number it has no right to compute.

There's also a security side. If the client sends { sku, quantity, price: 119 } and the server trusts the price, anyone with the browser's developer tools can buy the shoes for $1. The rule is simple and absolute: requests carry intent ("set line ln_8f2 to 2", "apply code SPRING10"), never money. Every number the client shows about money is either a copy of a server answer or a clearly labelled estimate.

Decision When and where should prices, stock, and discounts be checked?

The client adds it up

The client keeps the price from the product page with each line and computes subtotal and discounts itself. The server checks only when the order is placed.

Pros

  • No pricing call while browsing the cart.
  • Totals update instantly.

Cons

  • Shows stale or wrong totals, then surprises the shopper at the end.
  • Duplicates discount and tax rules in JavaScript, where they drift from the server's.
  • Surprise costs at the end are one of the top reasons carts are abandoned.

Server re-prices on every cart read

Every cart response is priced fresh: today's unit price, stock, discounts, and a status per line. The client renders it.

Pros

  • The cart is always as right as the last response.
  • One source of discount rules.

Cons

  • Prices can still change between the cart page and payment.

Re-price on every read, then pin a quote at checkout

As above, plus: starting checkout creates a quote with the priced lines, the cart version, and an expiry (say 15 minutes), and reserves the stock. Placing the order must match the quote.

Pros

  • The total the shopper reviews is the total charged, or the order is stopped.
  • Stock is held only for shoppers who are actually paying.

Cons

  • Quotes expire and must be refreshed if the shopper walks away.
  • More server work at checkout start.

What we pick: Re-price on every read and pin a quote at checkout. The cart page is always fresh, and the one moment where freshness costs real money, the payment, is protected by a quote that must match. Reserving stock only at checkout keeps bots and abandoned carts from locking up the best sellers.

Where the server re-checks, top to bottom. Add to cart: checks stock, per-order limit, still sold; the shopper sees sold out just now: said in the drawer. Every cart read: checks today's prices, stock, promos; the shopper sees a status and a sentence per line. Start checkout: checks a quote for 15 min, stock held; the shopper sees the total she will review. Save a step: checks new address: tax, shipping, delivery; the shopper sees re-quoted session, changes shown. Place order: checks cart version and total match the quote; the shopper sees 409 and back to review if not.Add to cartstock, per-order limit, still soldsold out just now: said in the drawerEvery cart readtoday's prices, stock, promosa status and a sentence per lineStart checkouta quote for 15 min, stock heldthe total she will reviewSave a stepnew address: tax, shipping, deliveryre-quoted session, changes shownPlace ordercart version and total match the quote409 and back to review if not
The client never decides a price. It shows the latest server answer, and the last check, at placing the order, stops any total the shopper didn't review.

Every surprise becomes a status with a message

The server compares each line with today's catalog and sets a status. The client never hides a change; it turns each status into one plain sentence next to the line and, where it helps, one action. The shopper should never need to work out what changed by comparing numbers.

StatusWhat happenedWhat the line saysCounts in subtotal
price_downToday's price is lower than priceAtAdd"Price dropped from $140 to $119"Yes, at $119
price_upToday's price is higher"Price went up from $40 to $45 since you added it"Yes, at $45
low_stockFewer than 5 left"Only 3 left"Yes
reducedWanted more than are left"Only 2 left, so we changed your quantity to 2"Yes, at the new quantity
limitWanted more than maxPerOrder"Limit 2 per order"Yes, capped
out_of_stockNone left"Sold out" with "Save for later" and "Remove"No
unavailableCan't ship to this region or discontinued"Not available for delivery to Germany"No
  • Don't leak exact stock. Return available capped at 20, so competitors can't read inventory from the cart API. The stepper's + button is disabled at the lower of available and maxPerOrder, and a hint says why.
  • Keep sold-out lines. Removing them silently makes the shopper think the site lost them. Keep them visible, out of the subtotal, with "Save for later" as the main action.
  • Promo codes are server answers too. POST /api/cart/promo { code } returns the cart with the discount applied or a reason it wasn't: expired, not valid for these items, or a minimum not met. Show the reason under the field, not a generic "invalid code".
  • Discounts can stop applying. Removing a line may drop the cart below a code's $50 minimum. The server removes the discount and returns a notice: "SPRING10 needs $50, your cart is $42." The client shows it where the discount line used to be.
  • Label estimates. Before an address is known, tax and shipping are estimates ("Shipping: calculated at checkout" or "Estimated tax: $9.52"). Say so, so the final total isn't a surprise.

One subtlety: product pages are cached at the CDN for about 60 seconds, so for a minute after a price change the product page and the cart can disagree. That's fine: the cart is never cached (Cache-Control: private, no-store), and the cart's price is the one that counts.

Check your understanding · Exercise 7

Which requests from the client are safe for the server to act on (select all that apply)? Select all that apply.

Cart drawer, cart page, or both

After "Add to cart", the shopper needs proof it worked and a way to keep going. The naive choices sit at the extremes: navigate to the cart page after every add (proof, but it throws the shopper out of browsing), or only bump the header count (no interruption, but easy to miss, and nobody can tell which size was added).

Decision What should the shopper see right after adding, and where do they edit the cart?

Go to the cart page after each add

Adding navigates to /cart.

Pros

  • Clear proof, full details, one place to edit.

Cons

  • Interrupts browsing; shoppers adding several items must go back every time.
  • A full page load on every add.

A small confirmation only

A toast or a badge animation says "Added"; the cart page is the only place to edit.

Pros

  • No interruption.

Cons

  • Easy to miss, especially for screen reader and magnifier users.
  • No quick way to fix the wrong size.

A drawer after adding, plus a full cart page

Adding opens a side drawer with the cart's lines, a subtotal, and buttons for "Checkout" and "View cart". The cart page at /cart has the full view: saved for later, promo codes, shipping estimates.

Pros

  • Proof and a quick fix without leaving the product page.
  • The page is a real URL for bookmarks, deep links from emails, and the back button.

Cons

  • Two views of the same cart to build and keep consistent.
  • A drawer is a modal dialog, with all of a dialog's focus rules.

What we pick: Both. The drawer gives proof and a quick edit in place; the page is the full, linkable view. They read the same cart store and use the same line component, so keeping them consistent is free. On small phones the drawer can cover the full screen, which is fine because it closes with one tap or the Escape key.

Two views of one cart. Left: a product page with a modal drawer on its right side; the page behind is inert. The drawer says Added to cart, lists shoes times 1 and socks times 2, a subtotal of 143 dollars, a Checkout button, and a View cart link. Right: the cart page at slash cart, with the same two lines, a total of 143 dollars, a saved for later list with 1 item, a promo code field, and a Checkout button.Product pagepage (inert)Added to cartShoes x 1Socks x 2Subtotal $143CheckoutView cart/cartShoes x 1Socks x 2$143Saved for later: 1Promo codeCheckoutdialog: focus in, Esc closesfull view at a real URL
The drawer is a modal dialog: focus moves in, the page behind is inert, and Escape closes it. The page is the full view with a real URL. Both read the same cart store.

The drawer is a modal dialog, so build it as one: the native dialog element opened with showModal() gives the right role, moves focus in, makes the page behind it inert, and closes on Escape.

Put focus on the drawer's heading ("Added to your cart", made focusable with tabindex="-1") so a screen reader announces it, and make sure focus returns to the "Add to cart" button on close. Current browsers do this for a modal dialog; a custom drawer must do it by hand.

Keep the drawer's code in its own chunk, loaded when the shopper first hovers or focuses "Add to cart", so product pages don't pay for it up front.

Check your understanding · Exercise 8

Which choices make the cart drawer work well (select all that apply)? Select all that apply.

Checkout as a state machine with saved progress

Checkout collects four things in order: where to ship, how fast, how to pay, and a final yes. The naive build is a few step components with flags (step, isLoading, hasAddress, isPaying) in React state.

Then a shopper reloads on the payment step and lands on an empty address form. Another presses back and leaves checkout entirely. A third goes back, changes the country to Canada, and still sees a US shipping price, because nothing told the shipping step its input changed. Flags also allow impossible combinations, like paying while the address is invalid.

Decision How should the checkout flow be structured, and where does its progress live?

One long page, one submit

Every field on one page; one button at the bottom validates and pays.

Pros

  • Simple routing; the shopper sees everything at once.

Cons

  • Shipping prices depend on the address and tax depends on both, so parts of the page keep recalculating.
  • One error near the top is easy to miss on a phone.
  • Hard to measure where shoppers drop off.

Steps in component state

A wizard that swaps step components; all data in memory until the end.

Pros

  • Quick to build.

Cons

  • A reload, a crash, or a bank redirect loses everything.
  • The back button leaves checkout.
  • Flags drift into impossible states.

Steps with a URL each, a state machine, and a server checkout session

/checkout/:id/address, /shipping, /payment, /review. Each completed step is saved to a server-side checkout session. A small state machine decides which step is allowed and what an event does.

Pros

  • Reload, back button, and redirects all resume in the right place.
  • Impossible states can't be written down.
  • Each step is a page view in analytics.

Cons

  • More design up front: states, events, and guards.
  • A save request per step.

What we pick: Steps with URLs, a state machine, and a server session. The redirect to a bank or PayPal alone rules out in-memory state, and the machine turns the rules ("no shipping without an address") into data instead of scattered ifs. Returning shoppers with a saved address and card skip straight to review, so the steps cost them nothing.

The checkout state machine. Four steps in a row: address, shipping, payment, review. From review, PLACE goes to placing. From placing, PAID goes to confirmed, a 409 goes back to review, DECLINED goes back to payment, and a 3-D Secure request goes to needs_action, which goes to confirmed when paid. Notes: a reload lands on resumeStep, each step has a URL, and unknown events change nothing.addressshippingpaymentreviewplacingPLACE409DECLINEDneeds_action3DSconfirmedPAIDPAIDreload: resumeStepa URL per stepunknown eventschange nothing
Placing has no PLACE event, so a second click does nothing. Each step can also go back to an earlier one; the server then clears any saved choice that no longer fits, and resumeStep sends the shopper to it.

A state machine is a list of states, the events each state accepts, and where each event goes. Anything not in the list is ignored, which is how it rules out impossible states. Ours has a state per step, plus the states of placing the order. Guards are conditions on a transition: the step for shipping is reachable only if the session has a valid address.

ts
type Step = "address" | "shipping" | "payment" | "review";
type State = Step | "placing" | "needs_action" | "confirmed";

const machine: Record<State, Partial<Record<string, State>>> = {
  address: { ADDRESS_SAVED: "shipping" },
  shipping: { METHOD_SAVED: "payment", EDIT_ADDRESS: "address" },
  payment: { PAYMENT_READY: "review", EDIT_SHIPPING: "shipping" },
  review: { PLACE: "placing", EDIT_PAYMENT: "payment", EDIT_ADDRESS: "address" },
  placing: { PAID: "confirmed", ACTION_REQUIRED: "needs_action", DECLINED: "payment", CART_CHANGED: "review" },
  needs_action: { PAID: "confirmed", DECLINED: "payment" },
  confirmed: {},
};

export function next(state: State, event: string): State {
  return machine[state][event] ?? state; // unknown events change nothing
}

// Where a reload lands: the first step the session can't skip.
export function resumeStep(s: CheckoutSession): Step {
  if (!s.address?.valid) return "address";
  if (!s.shippingMethod) return "shipping";
  if (!s.paymentMethod) return "payment";
  return "review";
}
There's no event that goes from placing back to placing, so a second click on "Place order" while placing does nothing.

Saved progress, and what invalidates it

  • Save each step to the server when it completes: PATCH /api/checkout/:id { address }. The answer is the whole session, re-priced: a new address can change tax, shipping options, and which items can be delivered.
  • Half-typed forms go to sessionStorage, keyed by checkout id, so a reload keeps them. Never the card number: card fields belong to the payment provider and are never in our storage at all.
  • Changing an earlier step clears what depends on it. A new country clears the shipping method if it isn't offered there, and the shopper lands on shipping again with a note. The machine stays in its states; the server decides which saved choices are still valid.
  • Sessions expire. After the 15-minute quote runs out, the next save gets a fresh quote. If anything changed, the review step shows what and why before the shopper can pay.
  • A reload on any URL resumes correctly. Opening /checkout/ch_9/review with no payment method redirects to /checkout/ch_9/payment, using resumeStep.

Check your understanding · Exercise 9

Priya is on review. She goes back to address and changes the country from the US to Canada. What should happen (select all that apply)? Select all that apply.

Payment: hosted fields, redirects, 3-D Secure, and never charging twice

The naive payment step is our own form: card number, expiry, and CVC inputs, posted to our server, which calls the payment provider. It puts every card number through our JavaScript, our servers, and our logs, which pulls the whole site into the strictest part of PCI DSS, the card industry's security standard: hundreds of requirements, a yearly assessment, and a breach that leaks cards instead of cart contents.

The second naive mistake is the button. A shopper on a slow train taps "Place order", sees nothing for two seconds, and taps again. Or the request reaches the server, the card is charged, and the reply is lost in a tunnel; the page shows an error, and the shopper tries again. Both stories end with two charges unless the design makes the second attempt harmless.

Decision How should card details be collected?

Our own card form

Plain inputs on our page; our server receives the card number and sends it to the provider.

Pros

  • Full control of the look and the flow.

Cons

  • Card data touches our code and servers: the largest PCI scope (SAQ D, the longest self-assessment questionnaire).
  • Even if our script posts the card straight to the provider instead, our page still controls the card data, which means SAQ A-EP: far more requirements than SAQ A.
  • Any injected script on the page can read card numbers.
  • We must handle 3-D Secure ourselves.

Hosted fields (iframes from the provider)

Each card input is a small iframe served by the provider, styled to match our page. Their script turns the card into a token or confirms a payment; our code only sees the result.

Pros

  • Card data never touches our JavaScript or servers. When every card field is in the provider's iframes, the store qualifies for the smallest scope (SAQ A).
  • The shopper never leaves the page.
  • The provider's script runs 3-D Secure for us.

Cons

  • Styling is limited to what the provider allows.
  • Our checkout page still loads the provider's script and must be protected from tampering.

Redirect to a hosted payment page

The shopper is sent to the provider's page (or PayPal's), pays there, and comes back to our return URL.

Pros

  • The same smallest scope (SAQ A) and the least work; the provider owns the whole payment UI.
  • Required anyway for some methods, like PayPal and many bank payments.

Cons

  • The shopper leaves our site; some never come back.
  • We must restore checkout state when they return, from the server session.

What we pick: Hosted fields for cards, the provider's buttons for Apple Pay and Google Pay, and a redirect only for methods that need one, like PayPal. That keeps card data out of our code and our audits while the shopper stays on our page for the common case. The checkout state lives on the server, so the redirect case resumes cleanly when the shopper returns.

3-D Secure: when the bank wants to check

3-D Secure (3DS; today version 2, also called EMV 3DS) lets the card's bank confirm the shopper is the card holder. In the European Economic Area and the UK, strong customer authentication rules require it for most online card payments, with exemptions such as small amounts.

Most of the time the check is invisible ("frictionless"): the bank decides from device and purchase data the store sends. Sometimes the bank shows a challenge: a code by text message or an approval in its app, displayed in a frame on our page or as a redirect.

So a payment has three outcomes, not two: succeeded, declined, or needs action. In our machine, needs action is a real state: the client shows the challenge, waits, and asks the server for the final result; it never assumes success because the challenge window closed.

Paying by card with 3-D Secure, between the Browser, our BFF, the payment Provider, and the Bank. The card goes into the provider's hosted fields, and the provider returns a payment method id. The browser asks the BFF to place the order with an Idempotency-Key. The BFF confirms the payment with the provider using the same key, and the provider answers requires_action. The BFF answers 202, needs action. The browser shows the bank's 3-D Secure challenge. Then the browser asks the BFF for the status, and the BFF answers 200 with the order id.BrowserOur BFFProviderBankcard in iframepayment method idplace + keyconfirm, same keyrequires_action202: needs action3DS challengestatus?200 orderId
The card number goes only to the provider, never through our code. After the challenge, the browser asks our server for the result instead of trusting that the challenge window closed.

Idempotency keys: why a double click never charges twice

An operation is idempotent when doing it twice has the same effect as doing it once. Placing an order isn't naturally idempotent, so we make it so with three layers. First, the button: the machine leaves review for placing on the first click, and placing has no PLACE event, so the second click does nothing.

Second, the request: the client sends POST /api/checkout/:id/place with an Idempotency-Key header. The server stores the key with the result; a repeated key gets the stored result back instead of a new charge. Payment providers work the same way: Stripe, for example, remembers a key for at least 24 hours and rejects a reused key sent with different parameters.

Third, the data: one checkout session can produce at most one paid order, enforced by a unique constraint in the database, so even a bug in the first two layers can't create a second order.

ts
// One key per attempt to pay, kept across reloads of this tab.
function attemptKey(checkoutId: string): string {
  const name = `pay-attempt:${checkoutId}`;
  let key = sessionStorage.getItem(name);
  if (!key) {
    key = crypto.randomUUID();
    sessionStorage.setItem(name, key);
  }
  return key;
}

export async function placeOrder(checkoutId: string, expectedTotal: number) {
  return fetch(`/api/checkout/${checkoutId}/place`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": attemptKey(checkoutId) },
    body: JSON.stringify({ expectedTotal }),
  });
}

// After a decline, the shopper may change the card: that's a new attempt.
export function newAttempt(checkoutId: string) {
  sessionStorage.removeItem(`pay-attempt:${checkoutId}`);
}
A retry after a timeout reuses the key, so the server answers with the first attempt's result. Our server passes the same key, or one derived from it, on to the payment provider, whose API accepts one for the same reason.

Check your understanding · Exercise 10

Which statements about the payment step are true (select all that apply)? Select all that apply.

Placing the order, confirming it, and recovering from errors

"Place order" is the one request in the whole design where the client must not guess. The naive client treats it like any other call: success shows a thank-you message, anything else shows "Something went wrong, please try again". That message is a trap. If the request timed out after the card was charged, "try again" invites a second payment, and the shopper who is careful enough not to retry is left not knowing whether they bought anything.

So the client treats the answer as one of five outcomes, each with its own next state in the machine. The important one is the fifth: when the client doesn't know, it says so honestly and finds out, instead of picking an answer.

The five outcomes of placing an order. 200 orderId leads to confirmation page. 202 action leads to 3-D Secure, then ask. 402 declined leads to back to payment. 409 changed leads to back to review. timeout leads to retry same key, poll.Place order200 orderIdconfirmation page202 action3-D Secure, then ask402 declinedback to payment409 changedback to reviewtimeoutretry same key, poll
Four answers are clear. The fifth, a timeout, means the client doesn't know, so it retries with the same idempotency key and asks for the status instead of saying the payment failed.
AnswerMeaningWhat the client does
200 with orderIdPaid, order createdReplace the URL with the confirmation page, clear the attempt key, broadcast the empty cart
202 with requires_actionThe bank wants 3-D SecureShow the challenge, then ask the server for the final status
402 with a decline codeThe card was declinedBack to payment with a plain reason; keep everything else; a new attempt key
409 with a new quoteCart, prices, or stock changed, or the quote expiredBack to review with the changes highlighted; the shopper confirms again
Timeout or network errorUnknown: the charge may or may not have happenedShow "Checking your payment", retry with the same key or poll the status
ts
async function submitOrder(id: string, expectedTotal: number) {
  send("PLACE");
  for (let attempt = 0; attempt < 4; attempt++) {
    try {
      const res = await placeOrder(id, expectedTotal); // same key every time
      const body = await res.json();
      if (res.status === 200) return finish(id, body.orderId);
      if (res.status === 202) return send("ACTION_REQUIRED", body);
      if (res.status === 402) return send("DECLINED", body.reason);
      if (res.status === 409) return send("CART_CHANGED", body.quote);
      throw new Error(`unexpected ${res.status}`);
    } catch {
      showChecking(); // "Checking your payment. Please don't pay again."
      await wait(1000 * 2 ** attempt); // 1 s, 2 s, 4 s, 8 s
    }
  }
  showUnknown(id); // "We'll email you within 10 minutes", with a link to order status
}

function finish(checkoutId: string, orderId: string) {
  newAttempt(checkoutId);
  router.replace(`/orders/${orderId}/confirmation`); // back button skips checkout
}
Retrying with the same idempotency key is safe, so the unknown case retries a few times with backoff before falling back to an honest message.

A confirmation page that survives a reload

The confirmation is a real page at /orders/:orderId/confirmation, rendered from the order on the server, not from data the checkout happened to hold in memory. Reloading it, bookmarking it, or opening it from the email shows the same order. The client reaches it with a URL replace, not a push, so the back button returns to the store instead of the review step.

The server empties the cart when it creates the order; the client posts the new, empty cart on the BroadcastChannel so other tabs drop their badge to zero. Guests see the page through a short-lived signed token in a cookie, so an order number alone never reveals an address.

Redirect methods return the same way. PayPal sends the shopper back to /checkout/:id/return with some query parameters. The client never trusts them: it asks our server, which asks the provider, and only then moves to confirmation or back to payment. Card payments with a 3-D Secure redirect use the same return route.

Errors and recovery at every step

StepWhat goes wrongRecovery
CartAdd fails, item sold out a second agoLine-level message in the drawer, count restored, "Notify me" offered
AddressInvalid postal code, address not foundField errors on blur and on submit; an address suggestion the shopper can accept or ignore
ShippingNo method ships to this addressSay which items can't ship there; offer to remove them or change the address
PaymentDecline, wrong CVC, provider script failed to loadPlain reason from the decline code; keep the form; offer other methods if the script fails
ReviewPrices or stock changed, quote expiredHighlight each change; require a new confirmation
PlacingTimeout, unknown outcomeRetry with the same key, then poll the status; never say "failed" without knowing
  • Never lose what the shopper typed. A failed step keeps every field; only the card fields may need to be entered again, and the message says so.
  • Say what happened and what to do next, in the shopper's terms: "Your bank declined this card. Try another card or contact your bank." Map provider decline codes to a few plain messages; never show the raw code.
  • Move focus to the error. On a failed submit, focus a summary at the top of the step that links to each field with a problem (chapter 13 has the details).

Check your understanding · Exercise 11

The "Place order" request times out after 15 seconds. What should the client do (select all that apply)? Select all that apply.

Architecture, data model, and API

The pieces fall out of the decisions. On the client, one cart store holds cart data in memory; the badge, the drawer, and the cart page are views of it. A mutation queue owns all cart writes, tab sync owns the channel and the snapshot, and the checkout machine owns which step is allowed. A small payment adapter wraps the provider's script, so adding PayPal touches one module.

On the server, a backend-for-frontend (BFF: an API layer built for this one frontend) answers in cart and checkout shapes and talks to pricing, inventory, tax, and payments behind it.

The architecture. On the server side: the BFF, which handles cart, merge, checkout, and placing orders, with pricing, stock, and tax behind it; and the external payment provider. In the browser, bottom to top: Cart views send intents to the Cart store; the Cart store sends changes to the Mutation queue, which sends PUT and POST to the BFF. The Cart store also feeds Tab sync, which tells other tabs. Checkout pages send events to the Checkout machine, which saves the session at the BFF and hands payment to the Payment adapter, which talks to the provider for hosted fields and 3-D Secure.ServerBFFcart, merge, checkout; pricing behindProvidercards, 3DSBrowserMutation queueone in flightCheckout machinestates, guardsPayment adapterhosted fieldsCart storeconfirmed + pendingCheckout pagesone per stepCart viewsbadge, drawer, pageTab syncchannel, snapshotOther tabsPUTPATCHtoken
Every write goes through one module: the mutation queue for the cart and the checkout machine for checkout. Card data stays inside the payment adapter and the provider.
PartResponsibilityWhy the boundary is here
Cart viewsHeader badge, drawer, cart page: render and dispatch intentsThree views, one truth: none of them fetch or store the cart
Cart storeConfirmed cart and version, pending changes, derived viewOne place to apply the version rule and the optimistic overlay
Mutation queueCoalesce taps, one request in flight per line, roll back on failureRace handling lives in one tested module, not in every button
Tab syncBroadcastChannel messages, snapshot, refetch on visibilitySync policy can change without touching views
Checkout machineStates, events, guards, resume stepFlow rules as data; step pages stay simple forms
Payment adapterLoads the provider script, mounts hosted fields, runs 3-D SecureCard data and provider quirks stay in one place, loaded only on the payment step
BFFCart, merge, checkout session, quote, place orderPricing and money rules on the server, in the shape the client needs

The data model follows the same rule: the server owns everything about money and the client owns only what it's waiting on. Money is always an integer amount in the currency's minor unit, plus an ISO 4217 currency code: $119.00 is 11900 cents, 1,500 yen is 1500 because yen have no minor unit, and a few currencies such as the Kuwaiti dinar use three decimals.

Integers matter because 0.1 + 0.2 is not 0.3 in floating point, and a cart that's off by a cent fails reconciliation with the payment provider.

ts
type Money = { amount: number; currency: string }; // 11900 + "USD" is $119.00

type CartLine = {
  lineId: string; sku: string; quantity: number;
  title: string; image: string; options: Record<string, string>;
  unitPrice: Money; priceAtAdd: Money; lineTotal: Money;
  available: number; maxPerOrder: number; status: LineStatus;
};

type Cart = {
  id: string; version: number; currency: string;
  lines: CartLine[]; saved: CartLine[];
  discounts: { code?: string; label: string; amount: Money }[];
  subtotal: Money; estimatedTax?: Money; notices: Notice[];
};

type CheckoutSession = {
  id: string; cartVersion: number; quoteExpiresAt: string;
  address?: Address; shippingMethod?: string; paymentMethod?: string;
  totals: { items: Money; shipping: Money; tax: Money; total: Money };
  state: "open" | "placing" | "needs_action" | "completed";
};

// Client only, in memory.
type Pending = Map<string, { wanted: number; inFlight: boolean; timer: number }>;
Everything above Pending comes from the server and is replaced whole. The client never edits a Money field.
http
GET    /api/cart                      If-None-Match: "v12"  -> 304 or Cart
POST   /api/cart/items                Idempotency-Key  { sku, quantity } -> Cart
PUT    /api/cart/items/:lineId        { quantity }  (0 removes) -> Cart
POST   /api/cart/items/:lineId/move   { to: "saved" | "cart" } -> Cart
POST   /api/cart/promo                { code } -> Cart, or 422 { reason }
POST   /api/checkout                  -> CheckoutSession (quote, stock held)
PATCH  /api/checkout/:id              { address } | { shippingMethod } -> CheckoutSession
POST   /api/checkout/:id/place        Idempotency-Key  { expectedTotal }
                                      -> 200 { orderId } | 202 { action } | 402 | 409
GET    /api/checkout/:id/status       -> { state, orderId? }

Error body, every endpoint:
{ "error": { "code": "quote_expired", "message": "Prices were updated.", "retryable": false } }
Every cart write answers with the whole cart, so the client has one way to accept server state. The merge has no public endpoint: it runs inside sign-in.

Check your understanding · Exercise 12

Why does every cart write return the whole cart instead of just the changed line (select all that apply)? Select all that apply.

Performance, accessibility, internationalization, and funnel analytics

A cart and checkout is where speed, clarity, and trust turn directly into revenue. This chapter goes one level deeper than a checklist in the four areas interviewers ask about most.

Fast add to cart and a good INP

INP measures the time from a tap to the next frame painted, and "Add to cart" is the tap that matters most on a product page. Keep its handler tiny: update the store, bump the badge, open the drawer, and return.

Everything else (the network request, the analytics event, preloading the cart page) runs after the frame, by yielding to the browser first with scheduler.yield() where it exists and a setTimeout fallback. Load the drawer's code when the shopper hovers or focuses the button, so the first open doesn't wait for a download. On a mid-range phone this keeps the interaction well under the 200 ms "good" threshold, even though the server takes 150 to 300 ms to confirm.

  • Checkout pages render on the server so the form appears without waiting for JavaScript; they are personal, so Cache-Control: private, no-store.
  • Load the payment provider's script only on the payment step, and add a preconnect to its origin on the shipping step, so the hosted fields appear quickly when needed without slowing the first steps.
  • Keep third-party tags off checkout. Each one costs main-thread time and adds to the script risk from chapter 10.

Accessibility: announcements and forms

Most cart changes happen away from where the eye or the screen reader cursor is. Use one polite live region (role="status") for cart news: "Trail shoes, size 42, added. Your cart has 3 items." For the stepper, announce the settled result once the server answers ("Quantity of wool socks updated to 2, subtotal $143"), not every tap.

Give each stepper button a name that includes the product ("Increase quantity of wool socks"), and when + is disabled at the limit, link the reason with aria-describedby. Make targets at least 24 by 24 CSS pixels (WCAG 2.2), and 44 in practice on phones.

  • Every field has a visible label and the right autocomplete token (shipping street-address, shipping postal-code, email, tel), so browsers and password managers fill the form in one tap.
  • On a failed submit, move focus to an error summary at the top of the step that links to each field; mark fields with aria-invalid and connect messages with aria-describedby. Don't validate on every keystroke.
  • Don't ask twice. A "Billing address same as shipping" checkbox, checked by default, meets WCAG 2.2's rule against redundant entry and saves a whole form.
  • Respect reduced motion for the drawer's slide-in, and never block paste in any field.

Internationalization: currencies, taxes, and addresses

Each store region has one currency, and the server prices the cart in it; the client never converts. Format with Intl.NumberFormat(locale, { style: "currency", currency }) after dividing the integer amount by 10 to the power of the currency's decimals, which resolvedOptions().maximumFractionDigits reports (2 for USD, 0 for JPY, 3 for KWD). The formatter knows the symbol, the separators, and the order: German puts the symbol after the number ("119,00 €").

Tax display differs too: EU and UK consumer prices must include VAT, so the subtotal already contains tax, while US prices add sales tax once the delivery address is known, and the cart must say which ("incl. VAT" or "Tax calculated at checkout").

Addresses vary the most: field order, which fields exist, and whether a postal code is required all depend on the country. Ask for the country first, then render that country's form from data (Google's open-source address metadata is a common source), use one "Full name" field, and lay out the whole flow with logical CSS properties so right-to-left languages work.

Funnel analytics

Track one event per funnel step, with the cart or checkout id and the value: view_cart, add_to_cart, begin_checkout, add_shipping_info, add_payment_info, purchase (these are the GA4 ecommerce names, which most tools understand). Send client events with navigator.sendBeacon so leaving the page doesn't drop them, and send purchase from the server when the order is created, because ad blockers and closed tabs lose client events exactly at the most important step.

Add the reasons, not just the counts: validation errors per field, decline codes, 409 conflicts, time spent per step. A drop at the shipping step with many postal code errors points at the form, not the prices.

An example funnel, per 1,000 cart views. view_cart: 1000, begin_checkout: 620, add_shipping_info: 480, add_payment_info: 390, purchase: 310. The biggest drop, 38 percent, is from the cart to starting checkout.view_cart1,000begin_checkout620-38%add_shipping_info480-23%add_payment_info390-19%purchase310-21%
Example numbers, not benchmarks. Counts say where shoppers leave; the reasons (field errors, declines, 409s, time per step) say why.

Check your understanding · Exercise 13

Which of these improve the cart and checkout for real shoppers (select all that apply)? Select all that apply.

Running it in the interview

Forty-five minutes is not enough to say everything in this guide, so the skill is choosing. Lead with the two scary requirements (the cart follows the shopper, the payment never happens twice), because they justify the server cart and the idempotent payment, and every later choice hangs off them. Draw the architecture early and simply: views, cart store, mutation queue, checkout machine, payment adapter, BFF. Then go deep where the interviewer leans.

MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions; write the numbers"Guests and accounts, many devices, server-decided prices, cards with 3-D Secure"
5 to 12Where the cart lives; draw the architecture"Server cart for everyone, guest id in an HttpOnly cookie, a snapshot only for the badge"
12 to 20Merge at sign-in; optimistic quantities"Keep the larger, capped, idempotent. Absolute quantities, one request in flight, a version"
20 to 30Checkout machine and payment"Steps with URLs and a server session. Hosted fields. Three layers against double charges"
30 to 38Revalidation, errors, the unknown outcome"The client shows, the server decides; a quote pinned at checkout; never say failed when unsure"
38 to 45Performance, accessibility, i18n, analytics; follow-upsPick two areas and go one level deep in each

Signals that separate levels: a junior answer has a cart page and a checkout form. A mid-level answer has a server cart, optimistic updates, and hosted card fields. A senior answer names the failure each choice prevents, with a number or a story: the delta that doubles on retry, the merge that runs twice, the timeout after the charge. Say trade-offs without being asked, and say what you'd measure.

Think first

Follow-up: "A flash sale has 500 units and 50,000 shoppers. What changes?"

Think first

Follow-up: "What if the shopper is offline when they change a quantity?"

Summary

  • Keep the cart on the server for guests and accounts alike, with a guest id in an HttpOnly cookie; the browser holds only a copy and a count snapshot for the first paint.
  • Merge at sign-in on the server, in one transaction: keep every line, keep the larger quantity for duplicates, cap by stock and limits, mark the guest cart merged, and tell the shopper what changed.
  • Make quantity changes optimistic but send absolute quantities, keep one request in flight per line, drop replies with an older version, and roll back to the last confirmed cart.
  • Sync tabs with BroadcastChannel and refetch with an ETag when a tab becomes visible; that also covers other devices without push.
  • The client shows money, the server decides it: re-price on every read, turn every change into a plain message, and pin a quote at checkout that the order must match.
  • Build checkout as a state machine with a URL per step and a server session, so reloads, back buttons, and bank redirects all resume correctly.
  • Take cards with hosted fields to stay in the smallest PCI scope, treat 3-D Secure as a real state, and stop double charges with three layers: the machine, an idempotency key, and one paid order per checkout.
  • When the outcome of placing an order is unknown, say so, retry with the same key, and poll; never tell the shopper it failed without knowing.

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 3
Mid-level 0 of 5

References

Concepts behind it

Try next