cf.completefrontend
My progress
SYSTEM DESIGN · #56

Design a checkout flow

The checkout of an online store: address, shipping, payment, review, and place order, for guests and signed-in shoppers, with express wallets. The hard parts are a total that changes with every address and code, card data that must never touch your servers, and a place-order request that must charge exactly once even when the network drops at the worst moment.

The question

Design the checkout for an online store. A shopper arrives from the cart, enters or picks a shipping address, chooses a delivery option, pays by card or with a wallet such as Apple Pay, reviews the order, and places it. Guests must be able to buy without an account.

The store sells in 20 countries, so taxes, address formats, and delivery options differ by destination. Prices, stock, and discount codes can change while the shopper is typing. Walk me through the page structure, how the total stays correct, how card data is handled, and how placing the order works when the network fails halfway.

Real-life examples: Shopify checkout, Amazon checkout, Stripe Checkout (hosted page), Stripe Payment Element embedded in a store

Requirements: what to ask before drawing anything

Checkout is the shortest part of a store and the one that earns all the money. Every other page exists to bring a shopper here. A checkout that is slow, confusing, or that fails once in a hundred orders costs revenue directly, and a checkout that charges twice costs trust that no page can win back.

Most carts never become orders. Baymard Institute averages 50 published studies to a documented cart abandonment rate of about 70 percent. Much of that is window shopping, but the reasons people give besides just browsing are a checkout to-do list: extra costs too high (40 percent), not trusting the site with a card (19 percent), being made to create an account (18 percent), a checkout that is too long or complicated (17 percent), and a declined card (10 percent).

It is also a place where three groups have hard rules. The shopper wants to pay fast and see the real total. The business wants every order recorded, taxed, and paid exactly once. The card networks and regulators decide how card numbers may be handled and when a bank may ask the shopper to prove who they are.

Spend the first five minutes on questions whose answers change the design. The table lists them, the answer this guide assumes, and what each answer changes.

QuestionAnswer we assumeWhat it changes
Do we build the payment page or redirect to a hosted one?We embed a payment provider's fields (Stripe, Adyen, or Braintree style) in our own page. A hosted page is the fallback we compare against.Decides the PCI DSS scope (which card-security rules we must meet), the layout control we have, and which scripts run on the payment page.
Can guests buy?Yes. In Baymard Institute's survey, 18 percent of US shoppers who abandoned an order (for a reason other than just browsing) did so because the site wanted them to create an account, so guest checkout is the default path.Session identity is a checkout id, not a user id. Account creation moves to the confirmation page.
Which payment methods?Cards, Apple Pay, Google Pay, PayPal, and a gift card balance. Saved cards for signed-in shoppers.Express buttons at the top, a provider iframe for cards, and a split payment for gift cards.
Which countries?20 countries, 6 currencies. Tax is computed by destination on the server.Address fields change per country. The total cannot be known until we have an address.
How do prices and stock change?Flash sales change prices hourly. Popular items sell out in minutes.The server re-validates the cart when the order is placed and the page must show what changed.
What traffic?A normal peak of 50 orders per second, a sale peak of 2,000 orders per second for 10 minutes.Place-order must be idempotent (safe to repeat without doing the work twice) and cheap to retry. Re-pricing calls must be debounced and cancellable.
Which devices?About 70 percent mobile web, often on a slow network in a shop or on a train.A small JavaScript budget, wallets that need no typing, and retries that never double-charge.
Does the store need strong customer authentication?Yes for European cards: banks may require a 3-D Secure challenge, an extra check run by the shopper's bank.The payment step must handle a challenge window and a redirect, and resume afterwards.

Functional requirements

  • Start checkout from the cart as a guest or signed in. Signed-in shoppers see saved addresses and saved cards.
  • Pay in one or two taps with an express wallet (Apple Pay, Google Pay, PayPal, Shop Pay style), which supplies the address and payment together.
  • Enter a shipping address with suggestions as you type, in the format of the destination country, and a billing address if it differs.
  • Pick a delivery option whose list and price depend on the address and the cart.
  • Apply and remove discount codes and gift cards and see their effect on the total right away.
  • Pay by card in fields that look like part of the page, pass a bank challenge when asked, and see a clear message when a card is declined.
  • Review the order with the exact total that will be charged, place it once, and land on a confirmation page that can be refreshed or shared with the order number.
  • After paying as a guest, create an account with one field: a password.

Non-functional requirements, with numbers

  • Exactly one charge per order. Double clicks, retries, refreshes, and a lost response must never create a second order or a second charge. This is the requirement every other choice serves.
  • The total shown is the total charged. The amount on the place-order button equals the amount the server charges, to the cent, or the shopper is asked to confirm a new amount.
  • Fast on a phone. The main content of the checkout renders within 2.5 seconds on a mid-range phone on 4G (Largest Contentful Paint, LCP, at most 2.5 s at the 75th percentile), and taps respond within 200 ms (Interaction to Next Paint, INP).
  • Re-pricing feels instant. A new address or code updates the total within 500 ms at the 75th percentile, with a visible pending state while it runs.
  • Card data never touches our servers. Only the payment provider sees card numbers. Our code sees a token.
  • Accessible. WCAG 2.2 AA: every field labelled, errors announced and linked, keyboard-only checkout possible, no surprise timeouts.
  • Observable. We can see drop-off per step and the error rate of each call, without sending names, emails, or addresses to analytics.

Out of scope, and why

  • The cart page itself, product pages, and recommendations: covered by the shopping cart and Amazon guides.
  • Fraud scoring and the payment provider's own systems. We call them; we do not design them.
  • Subscriptions and buy-now-pay-later plans. They add states but reuse the same session and payment design.
  • Order tracking after the purchase. The confirmation page is the last screen we own.

Check your understanding · Exercise 1

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

What makes checkout hard

A checkout looks like a long form with a button at the end. Five traits make it much harder than a form, and each one drives a later chapter.

  1. The total is a moving target. Subtotal, delivery, tax, discounts, and gift cards each depend on other inputs. Tax depends on the address. Delivery depends on the address and the weight of the cart. A discount may depend on the delivery option (free shipping codes) or exclude sale items. One change can ripple through every line, and the server, not the browser, owns the math.
  2. The money part is not yours. Card numbers must stay inside the payment provider's frames. The bank may interrupt with a challenge. The final yes or no comes from systems you do not control, sometimes seconds later, sometimes by webhook.
  3. The last request is the dangerous one. Every earlier request is safe to repeat. Placing the order moves money. If its response is lost, the shopper does not know whether they paid, and the naive fix (try again) can charge twice.
  4. The world changes under the shopper. A shopper may spend 4 minutes on checkout, or leave the tab for an hour. Meanwhile an item sells out, a flash price ends, or a code expires. The design must notice, explain, and ask, never silently charge a different amount.
  5. Every extra second and field costs orders. Large usability studies keep finding that long, confusing checkouts and surprise costs are among the top reasons people abandon a purchase. Speed, few fields, and honest totals are business requirements, not polish.

Put those together and the design has a clear center: a checkout session that lives on the server. It holds the cart snapshot, address, delivery choice, codes, the computed totals, and a version number. The browser edits the session through small calls and renders what the server says.

Placing the order is one idempotent call against one version of that session. Most of this guide is about getting that loop right.

TraitNaive approachWhat goes wrongWhere we fix it
Moving totalCompute the total in the browser from the cart.Tax rules differ by state and country; the browser shows 107.00 and the server charges 108.25.Re-pricing chapter: server-computed totals with a version
Card dataPost the card form to our API.Card numbers pass through our servers and logs, which puts our whole stack in PCI DSS scope.Payment chapter: provider iframes and tokens
Dangerous last requestDisable the button and POST /orders.A timeout after the charge leaves the shopper retrying, and a second POST charges again.Place-order chapter: idempotency key and session reconciliation
Changing worldTrust the prices loaded at the start.The order is placed at a price that ended 10 minutes ago, or for an item that is gone.Recovery chapter: re-validate at submit and show what changed
Cost of frictionShow every field and require an account.Shoppers abandon at the account wall or on a 20-field form.Flow and express chapters: guest default, wallets first, fewer fields

Think first

Why not keep all checkout state in the browser and send it in one big request at the end?

One page or many steps: the shape of the flow

The first visible decision is the shape of the checkout. Teams argue about it for months, so it is worth knowing what the research says before picking.

The naive view is that fewer pages always convert better, so everything goes on one long page. The other naive view is that small steps feel easier, so every field group gets its own page. Baymard Institute, which has run large checkout usability studies for over a decade, finds that neither layout wins by itself.

In their testing, people had relatively few problems moving between steps when each step followed a few simple rules. The usability problems came from what each step asked the shopper to do. A lean form works in both layouts and a bloated one fails in both. Their benchmark puts the average US checkout at about 15 form fields shown by default (23 form elements), while 7 or 8 fields can be enough.

So the layout question is really about engineering and context, not magic conversion numbers. Here are the three serious options.

Decision How should the checkout be laid out?

One long page

Contact, address, delivery, payment, and the order summary all visible on one scrolling page.

The total updates in a sticky summary as sections are filled.

Pros

  • The shopper sees everything that will be asked up front.
  • Returning shoppers with saved details may only need to press one button.
  • One URL: no step routing, no back-button questions.

Cons

  • On a phone, the page is long and the payment fields sit far below the fold.
  • Fields that depend on earlier answers (delivery options need the address) show empty or loading states.
  • Validation errors can be far from where the shopper is looking.

Separate pages per step

Information, shipping, payment, review, each a route.

Next saves the step to the server and navigates.

Pros

  • Each screen is short and focused, which suits phones.
  • Each step has its own URL, so analytics per step and back-button behavior are natural.
  • Later steps can assume earlier data exists.

Cons

  • Each step is a navigation; on a slow network every Next costs a round trip.
  • The shopper cannot see what comes later, so a surprise cost on step 3 feels like a trap.
  • Editing an earlier answer means going back and forward again.

One page with collapsible sections (accordion)

One route. Sections open one at a time for new data.

A completed section collapses to a one-line summary with an Edit link.

Returning shoppers start with all sections collapsed and filled.

Pros

  • Short on screen like steps, no navigation like one page.
  • Summaries let the shopper check earlier answers without reopening them.
  • Works for both new and returning shoppers with the same component tree.

Cons

  • Needs careful focus management when a section opens or closes.
  • Step analytics must be sent by code, since the URL does not change unless you add it.
  • Collapsed sections without summaries hide data and cause errors (a known pitfall in usability testing).

What we pick: We pick one route with collapsible sections and an always-visible order summary. It gives phones a short screen per task, avoids a navigation per step, and lets a returning shopper with saved address and card land directly on a ready-to-place order. We mirror the open section in the URL (?section=delivery) so the back button and refresh behave, and send step events by code. The wizard mechanics themselves (a step graph, per-step validation, the error summary) are the same as in the multi-step form guide, so we reuse them rather than re-derive them.

Wireframe of the checkout page. At the top left, three express buttons: Apple Pay, Google Pay, and PayPal, then the words or pay another way. Below, collapsible sections: Contact and Shipping are done and collapsed, Delivery is open with two options, Standard for 4.90 selected and Express for 12.00, and Payment is still closed. On the right, a sticky order summary shows subtotal 80.00, delivery 4.90, tax 6.40, and total 91.30, with a Pay 91.30 button under it.store.com/checkout/cs_123Apple PayGoogle PayPayPalor pay another wayContact: done, EditShipping: done, EditDeliveryStandard 4.90Express 12.00PaymentOrder summarySubtotal 80.00Delivery 4.90Tax 6.40Total 91.30Pay 91.30
Wallets come first so their users never see the form. Finished sections collapse to a summary with an Edit link, and the total plus the amount on the Pay button stay in view the whole time.

What goes in each section

SectionFields shown by defaultFields hidden until neededWhy
Express checkoutWallet buttons onlyNoneShown first so wallet users never see a form.
ContactEmailPhone, only if the carrier needs itEmail is the receipt address and the guest identity.
Shipping addressCountry, full name, address line 1, city, postal code, region where the country uses oneAddress line 2 behind a link, company name behind a linkEvery hidden field is one fewer to scan. Most people do not need them.
DeliveryOptions for this address, cheapest selectedNoneCannot render until the address is known.
PaymentCard fields in the provider frame, saved cards for signed-in usersBilling address: a checkbox, checked by default, says same as shippingMost billing addresses match the shipping one.
ReviewLine items, totals, place-order button with the amountNoneThe amount on the button is the amount charged.

Two details matter more than they look. First, the order summary is always visible: on desktop as a sticky right column, on a phone as a collapsed bar at the top that shows the total and expands to show line items. Not being able to see or calculate the total cost up front is itself a reason 12 percent of shoppers give for leaving, so the total is never more than one tap away.

Second, the place-order button names the amount, as in Pay 84.10 EUR. It turns the button into the last confirmation of the total and catches surprise costs before the click, not after.

Three checkout layouts as phone screens. One page: Contact, Address, Delivery, Payment, and Review are stacked on a single long page. Steps: one screen shows only the Address step, step 2 of 4, with a Next button. Sections: Contact and Address are collapsed and done, Delivery is open, and Payment waits below, all on one URL.one pageContactAddressDeliveryPaymentReviewstepsstep 2 of 4AddressNextsectionsContact: doneAddress: doneDeliveryPaymentlong scroll on a phonea load per stepone URL, short screen
The work for the shopper is the same in all three: the same fields. What differs is how much they see at once and how many navigations it takes. Collapsible sections give a short screen without a page load per step.

Check your understanding · Exercise 2

A team wants to move from a 4-page checkout to a one-page checkout because a blog post promised a big conversion lift. What should a senior engineer say? Select all that apply.

Guest checkout, accounts after purchase, and express wallets

The fastest form is the one the shopper never fills. Two decisions remove most of the typing: let people buy without an account, and let wallet users skip the form entirely.

Guest checkout: the email is the identity

The naive checkout starts with a sign-in wall: sign in or create an account to continue. As the requirements showed, being made to create an account is one of the most common reasons people abandon. Many shoppers buy once a year from a store and do not want another password.

Decision How does a shopper identify themselves at checkout?

Account required

Sign in or register before the first checkout field.

Pros

  • Every order belongs to a user. Simple data model.
  • Saved addresses and cards for everyone.

Cons

  • A wall at the moment of highest intent.
  • Password resets in the middle of a purchase lose orders.

Guest by default, sign in optional

The first field is email. A small link offers sign-in for saved details.

The checkout session belongs to a random checkout id stored in a cookie.

Pros

  • No wall. One field to start.
  • Signed-in shoppers still get saved data.
  • The same session can be adopted by an account later.

Cons

  • Orders without a user id need linking later by email.
  • Two paths to test.

Guest by default, plus account creation after purchase

As above, and the confirmation page offers: set a password (or a passkey) to track this order.

Email, name, and address are already known, so the account needs one more field.

Pros

  • Account creation moves to the moment it costs nothing: the money is already paid.
  • High uptake because the value (order tracking, faster next time) is obvious.

Cons

  • Needs a secure link between the order and the new account (a one-time token tied to the session, not just an email match).

What we pick: Guest by default, with account creation after purchase. The shopper types an email, and on the confirmation page sets a password or a passkey with everything else already filled. Signed-in shoppers keep their saved addresses and cards. The sign-in methods, passkeys, and session cookies themselves follow the sign up and sign in guide.

Two traps to avoid. Do not tell a guest that an account already exists for their email (that leaks who shops where, an account enumeration problem); instead offer an optional sign-in link and let them buy as a guest anyway. And when a guest becomes a user after purchase, link the order by the checkout session's one-time token, not just by matching the email, or anyone who types someone else's email could claim their order history.

Express checkout: wallets that skip the form

A wallet such as Apple Pay, Google Pay, PayPal, or Shop Pay already knows the shopper's address and card. In one sheet, the shopper confirms a shipping address, picks a delivery option, and authorizes payment with Face ID or a fingerprint. On a phone that turns minutes of typing into a few seconds.

What a wallet skips: the contact, address, delivery, and card sections. What it does not skip: server-side pricing, stock checks, the idempotent place-order call, and fraud checks. Because they skip so much, express buttons go at the very top of checkout, above the email field, and often also on the cart page and product page. Placing them after the form means the shopper has already typed everything the wallet would have filled.

Decision How do we integrate wallets?

Payment Request API directly

new PaymentRequest(methods, details, options) with method identifiers such as https://apple.com/apple-pay or https://google.com/pay.

The browser shows its own payment sheet.

Pros

  • A web standard, no provider script.
  • Shipping address and option change events built in.

Cons

  • Each wallet still needs its own merchant setup, tokens, and processing.
  • Support differs: Chromium browsers and Safari ship it; Firefox has it only behind a flag.
  • PayPal and Shop Pay are not Payment Request methods.

Each wallet's own SDK

Apple Pay JS, the Google Pay JavaScript API, the PayPal JS SDK, each with its own button.

Pros

  • Full control and every wallet feature.

Cons

  • Four SDKs, four event models, four sets of scripts on the payment page.
  • Each one adds JavaScript weight and a script to inventory for compliance.

The payment provider's express component

One provider component (for example Stripe's Express Checkout Element or Adyen's wallet components) renders whichever buttons the device supports.

It uses Payment Request or each wallet's SDK as needed and returns one kind of payment token.

Pros

  • One event model for all wallets, one token type for the server.
  • The provider detects which wallets the device offers and handles most of the wallet setup; we still register our domain with it.

Cons

  • Tied to that provider.
  • Less control over button order and look.

What we pick: Use the provider's express component. It hides the differences between wallets, returns the same token as card payments, and keeps the script count on the payment page low. We still need to understand the Payment Request flow underneath, because its events shape our server API.

Sequence of an express wallet payment between the shopper, the page, the wallet sheet, and the BFF. The shopper taps Apple Pay. The page calls show with a session that is already priced. The sheet shows the total. The shopper picks a new address, and the sheet sends a partial address to the page. The page asks the BFF to re-price, and the BFF replies with delivery options, tax, and a total at version 8. The page updates the sheet. The shopper authorizes, and the sheet returns a token and the full address. The page sends complete with version 8, the token, and an idempotency key.ShopperPageWallet sheetBFFtap Apple Payshow(), pricedsheet: total 91.30new addresspartial addressreprice(address)options, tax, v8updateWith(total)token, full addresscomplete(v8, key)
Two rules shape the API: show() runs inside the tap, so the total must be known before the button renders, and the address change event carries only a partial address, so re-pricing must work with country, region, city, and postal code.

The sequence has two engineering details that catch teams out. First, the sheet must open in direct response to the tap. The spec only allows show() during a user activation (a tap or key press the browser has just seen), and Safari is strict about it, so you cannot await a network call between the tap and show(). The total must already be known when the button renders, which means the checkout session is created and priced before the express buttons appear.

Second, the address in the change event is partial. Before the shopper authorizes, the browser redacts the address to what is needed to compute rates (country, region, city, postal code), leaving out the street lines, the recipient's name, and the phone. The full address arrives only after authorization. So the re-pricing endpoint must accept a partial address and return delivery options and an estimated tax for it.

ts
const request = new PaymentRequest(methods, toDetails(session), {
  requestShipping: true,
  requestPayerEmail: true,
});

request.addEventListener("shippingaddresschange", (event) => {
  // Partial address: country, region, city, postal code.
  event.updateWith(
    api.reprice(session.id, { address: request.shippingAddress })
      .then((next) => toDetails(next)),
  );
});

buttonEl.addEventListener("click", async () => {
  const response = await request.show(); // must run inside the tap
  const result = await placeOrder({ walletToken: response.details });
  await response.complete(result.ok ? "success" : "fail");
});
Opening a wallet sheet with the session already priced, and re-pricing when the shopper picks another address.

Check your understanding · Exercise 3

An express button awaits a fetch to compute the total and only then calls show(). On Safari it often fails. Which fixes are sound? Select all that apply.

Address entry: suggestions, country formats, and validation

The shipping address is the longest part of the form and the one with the costliest mistakes. A wrong postal code means a returned parcel, a refund, and a support ticket. It is also the input that drives delivery options and tax, so every keystroke here can change the total.

The naive form is the US form for everyone: street, city, state, ZIP. It breaks right away for a store in 20 countries. A required state field blocks a shopper in Germany, and a 5-digit ZIP check blocks everyone in Canada, whose codes look like K1A 0B1.

The differences run deep. The UK has postcodes but no states. Japan writes addresses from the largest unit to the smallest and puts the postal code first, often using it to fill the prefecture and city. Hong Kong has no postal codes at all, and Ireland has Eircodes that Google's address data treats as optional.

Country first, then the fields that country needs

The country select comes first, defaulted from the shopper's locale or the store region. Choosing a country swaps the field set: which fields exist, their labels (ZIP code, postcode, PIN code), which are required, their order, and the postal code pattern.

Do not invent this table yourself. Google publishes per-country address metadata (fields, required fields, labels, postal code patterns) through its open-source libaddressinput project, whose C++ library Chromium uses for its address forms. Many stores build on that data or on a provider's equivalent.

ts
type AddressField = "name" | "line1" | "line2" | "city" | "region" | "postalCode";

type CountryFormat = {
  rows: AddressField[][];
  required: AddressField[];
  labels: Partial<Record<AddressField, string>>;
  postalPattern?: RegExp;
};

const formats: Record<string, CountryFormat> = {
  US: {
    rows: [["name"], ["line1"], ["line2"], ["city", "region", "postalCode"]],
    required: ["name", "line1", "city", "region", "postalCode"],
    labels: { region: "State", postalCode: "ZIP code" },
    postalPattern: /^\d{5}(-\d{4})?$/,
  },
  GB: {
    rows: [["name"], ["line1"], ["line2"], ["city"], ["postalCode"]],
    required: ["name", "line1", "city", "postalCode"],
    labels: { city: "Town or city", postalCode: "Postcode" },
  },
  HK: {
    rows: [["name"], ["line1"], ["line2"], ["region"]],
    required: ["name", "line1", "region"],
    labels: { region: "Area" },
  },
};
Field layout per country as data. The form renders rows from this config instead of hard-coding one country.
Address forms for three countries. United States: full name, address line 1, then city, state, and ZIP code in one row. United Kingdom: full name, address line 1, town or city, then postcode, and no state. Japan: postal code first, which can fill prefecture and city, then prefecture, city, block and building, and name. A note says Hong Kong has no postal code field at all.USFull nameAddress line 1CitySTZIP5 or 9 digit ZIPUKFull nameAddress line 1Town or cityPostcodeletters and digitsJapanPostal codePrefectureCity, wardBlock, buildingNameHong Kong: no postal code field at all
The country decides which fields exist, what they are called, which are required, and their order. Render the form from per-country data rather than one hard-coded layout.

Let the browser fill it: autocomplete attributes

Before adding any address API, use what every browser already has. Browsers and password managers store addresses and fill a whole form in one tap, but only if the inputs say what they are. The HTML autocomplete attribute does that. A section token (shipping or billing) tells the browser which address the field belongs to, so it never mixes them.

Fieldautocomplete valueNote
EmailemailWith type=email so phones show the email keyboard.
Full nameshipping nameOne field. Splitting first and last names breaks many cultures.
Address line 1 and 2shipping address-line1, shipping address-line2Or one textarea with shipping street-address. Do not mix the two styles.
Cityshipping address-level2Called locality, town, or post town by country.
State, province, prefectureshipping address-level1Only where the country uses it.
Postal codeshipping postal-codeUse inputmode=text for countries with letters; never type=number.
Countryshipping countryA select with ISO 3166 codes as values.
Phoneshipping telWith type=tel. Ask only if the carrier needs it.
Billing fieldsbilling address-line1 and so onSame tokens with the billing section.

Suggestions as you type

Next, a type-ahead that turns 12 characters into a full, valid address. Google Places Autocomplete, Loqate, and Mapbox are common choices. The mechanics are the autocomplete guide's (debounce, cancel stale requests, keyboard listbox) with three checkout-specific twists.

  • Billing sessions. Places-style APIs bill per session: you send the same session token with each type-ahead request and with the final details request, so a whole lookup is billed as one session instead of one charge per keystroke. Create a new token per address entry, and debounce to about 150 to 250 ms so fast typists do not fire a request per key.
  • Fill the structured fields, keep them editable. Picking a suggestion fills line 1, city, region, and postal code, then moves focus to line 2 (flat or apartment number), the field suggestions most often miss.
  • Never trap the shopper. New buildings and rural addresses are often missing from the suggestions. A visible Enter address manually link must always be there, and the form must work fully if the suggestion script fails to load.

Validation and normalization

Format checks run in the browser (required fields, the postal code pattern for the country). Deliverability checks run on the server, when the shopper leaves the address section: an address validation service (Google's Address Validation API, a carrier API such as USPS in the US, or the provider's own) returns whether the address is deliverable and a normalized version, for example 123 main street apt 4 becomes 123 Main St Apt 4.

The UI rule is: suggest, do not overwrite. If the service proposes a change, show both versions and let the shopper pick, with the entered version one tap away. Services are wrong about new buildings and some rural addresses, and silently replacing what someone typed is how parcels go to the wrong place.

Block only on hard failures (the postal code does not exist in that country). We store both the normalized address and the shopper's raw input, so support can see what was really typed.

Check your understanding · Exercise 4

Which choices make the address form work across countries? Select all that apply.

The total: delivery options, tax, discounts, and gift cards

The order summary has five to eight lines: subtotal, discounts, delivery, tax, gift card, total. Each depends on others. This chapter is about keeping that summary correct while the shopper changes things, and making sure the number on the button is the number charged.

Here is a concrete case. A shopper in Austin, Texas has a linen shirt and two wool scarves, 80.00 USD in all. They pick Express delivery (12.00) and apply code SHIP50, half off delivery. Texas sales tax (8.25 percent in Austin) applies to the goods and, because the goods are taxable, to the delivery charge too.

Now they switch the address to Portland, Oregon. Oregon has no sales tax, the Express option is not offered to that postal code, and the code still applies but to a different delivery price. Four lines change from one edit, and a browser that tried to compute this would need every tax rule, carrier rate, and promotion rule. That is exactly the knowledge the server already has.

Decision Where is the total computed?

In the browser

Load prices, rates, and a tax table up front; compute locally on every change.

Pros

  • Instant updates, no network.

Cons

  • Tax rules across thousands of jurisdictions do not fit in a bundle.
  • Promotion rules and stock live on the server and change hourly.
  • The server must recompute anyway at payment, so the two will disagree.

On the server, after each change

Each edit (address, delivery option, code) is a small call that updates the session.

The server returns the whole priced session with a new version.

Pros

  • One source of truth: the same code prices the summary and the charge.
  • The client stays small.

Cons

  • Each edit costs a round trip, 100 to 400 ms on mobile.
  • Overlapping calls can return out of order.

Server totals, with a local estimate while waiting

The server prices as above.

While a call is in flight, the client shows the old values dimmed with a pending label, and may show instant local parts (for example the line item subtotal after a quantity change).

Pros

  • Feels responsive, never shows a wrong final number.
  • The button is disabled only while the total is pending.

Cons

  • Two code paths for the few lines estimated locally; they must be clearly marked as pending.

What we pick: The server computes every money number and returns the whole priced session with a version. The client shows a pending state on the affected lines (old values dimmed, a short Updating label) and disables the place-order button until the total is settled. We do not show locally computed totals as final. Optimistic UI is great for a like button (see the optimistic updates lesson), but for money the cost of a wrong number is higher than 300 ms of waiting.

Sequence of two re-pricing calls racing, between the shopper, the page, the session client, and the BFF. The shopper types postal code 10115, and the client sends a PATCH. The shopper then picks Standard delivery, and the client aborts the first call and sends a second PATCH. The BFF replies to the second with version 8 and total 84.10. A late reply with version 7 arrives afterwards. The client keeps version 8, drops version 7, and the page re-renders with 84.10.ShopperPageSession clientBFFpostal code 10115patch(address)PATCH, gets v7picks Standardabort, PATCH againv8, total 84.10late: v7keep v8: 84.10
Aborting saves work, but the version check is what keeps the summary right: the server may have applied the first change already, and its late reply must never overwrite a newer state.

Out-of-order responses and the session version

A shopper changes the postal code, then quickly picks a different delivery option. Two calls are in flight, and if the first response arrives last, the screen shows the total for the old option. This is a request race, and two tools fix it together.

First, the client aborts the earlier request with an AbortController when a newer edit to the same session starts. Second, the server stamps every response with the session version; the client ignores any response whose version is lower than the one it already shows. Aborting saves work; the version check is what guarantees correctness, since an aborted request may still have been applied on the server.

ts
let inFlight: AbortController | null = null;

async function updateSession(patch: SessionPatch) {
  inFlight?.abort();
  const controller = new AbortController();
  inFlight = controller;
  store.markPending(patch);
  try {
    const next = await api.patchSession(store.session.id, patch, {
      signal: controller.signal,
    });
    if (next.version > store.session.version) store.setSession(next);
  } catch (err) {
    if (!controller.signal.aborted) store.showError(err);
  } finally {
    if (inFlight === controller) store.clearPending();
  }
}
Every edit goes through one function that cancels the previous call and only accepts newer versions.

Delivery options that depend on the address and cart

The delivery list comes from the server with the priced session: each option has an id, a label, a price, and an estimated delivery date range. It depends on the destination and the cart (a sofa cannot go by letter post; some items cannot ship to some regions).

When the address changes, three things can happen to the selected option: it stays valid at the same price, it stays valid at a new price, or it disappears. The server picks a replacement (the cheapest) when it disappears and reports the change. The client then shows it in the change summary instead of quietly switching it. Show delivery dates, not only speeds: Arrives Tue 14 to Thu 16 Oct is easier to judge than 3 to 5 business days.

Tax: computed by destination, shown the local way

  • Before an address, show an estimate and say so. Use the country (and region if known) from the store locale: Estimated tax 6.40. Never show 0.00 as if it were final.
  • Inclusive versus exclusive. In the EU and UK, consumer prices include VAT, so the summary shows Including 16.67 VAT and the total does not jump. In the US, sales tax is added on top. The server returns which mode applies; the client renders the right wording.
  • Money as integers. Amounts travel as integer minor units with a currency ({ amount: 8410, currency: "EUR" }) and are formatted with Intl.NumberFormat. Floats lose cents (0.1 + 0.2 is not 0.3), and some currencies, such as the Japanese yen, have no minor unit at all.

Discount codes and gift cards

A discount code changes the price. A gift card pays part of it. They look similar in the UI but behave differently, and mixing them up causes bugs in tax and refunds.

AspectDiscount codeGift card
What it isA price reduction under rulesStored money: a payment method
Applied before or after taxBefore: it lowers the taxable amount in most placesAfter: it pays part of the final total
StackingRules decide: usually one code per order plus automatic promotionsSeveral cards allowed, then a card or wallet pays the rest
Can it stop applying?Yes: subtotal falls below a minimum, an excluded item is added, the code expiresOnly if the balance is used elsewhere first
UIA line under the subtotal with a Remove buttonA line under the total, then Remaining to pay
AbuseCode guessing: rate limit and lock outNumber guessing: strict rate limits, a PIN, bot checks

When a code stops qualifying, do not delete it. Keep it attached and show why it is not applied, as in Code SAVE10 needs a subtotal of 60.00: add 5.05 more. Deleting it makes the shopper think they mistyped it.

When gift cards cover the whole total, the card fields hide and the button changes to Place order: no extra charge. The code entry field itself sits collapsed behind a Have a code? link: an open, empty code box sends many shoppers off to search for codes in another tab, and some never come back.

Try it below. Change the destination, the delivery option, the code, and the stock, and watch which lines the server recomputes and what the change summary tells the shopper.

Address
Delivery
Code
What changed (version 7):
  • Start: Austin, Standard delivery, no code. Version 7.
Order summary at version 7. Subtotal (shirt x1, scarf x2): 80.00. no code: -0.00. Delivery: standard: 4.90. Tax for this address: 7.00. Total charged: 91.90. Pay button shows 91.90.Subtotal (shirt x1, scarf x2)80.00no code-0.00Delivery: standard4.90Tax for this address7.00Total charged91.90session version 7Pay 91.90
Hatched lines changed in the last edit. One input can move four lines, which is why the server returns the whole priced session, not one number.

Check your understanding · Exercise 5

Two re-pricing calls are in flight: postal code first, then delivery option. The postal code response arrives last. What keeps the summary correct? Select all that apply.

Payment: card fields we never see, bank challenges, and declines

The naive payment form is four inputs in our own page (number, expiry, CVC, name) posted to our API, which forwards them to the payment provider. It works, and it is the most expensive choice we could make. Every server, log, and script that touches a card number falls under PCI DSS (the Payment Card Industry Data Security Standard, the security rules that card networks require of anyone handling card data). If card numbers pass through our API, the whole backend is in scope, and a single console.log(body) writes card numbers to a log file.

Merchants prove compliance with a Self-Assessment Questionnaire (SAQ), and which one applies depends on how the card form is built. That makes the payment form an architecture decision, not a styling one.

Decision Who renders the card fields?

Redirect to a hosted payment page

The shopper leaves our site for a provider page (Stripe Checkout, an Adyen hosted page) and comes back with a result.

Pros

  • The smallest compliance scope: SAQ A.
  • The provider maintains the form, wallets, and 3-D Secure.

Cons

  • A visible jump to another domain mid-checkout.
  • Limited control over layout and the order summary.
  • The return trip must reload our page and resume the session.

Provider fields inside iframes on our page

A provider script mounts iframes served from the provider's domain (Stripe Payment Element, Adyen Components, Braintree Hosted Fields).

Card inputs live inside the frames. Our page gets events and, on submit, a token.

Pros

  • Looks native; the summary stays on screen.
  • Card data never reaches our page's JavaScript or our servers.
  • Can stay in SAQ A when the provider and our page meet its criteria.

Cons

  • Our page hosts the frames, so a script injected into our page could replace them with fake fields. That is why the page itself still needs script controls.
  • Styling is limited to the options the provider exposes.

Our inputs, provider JavaScript tokenizes

Our page renders plain inputs. A provider script reads them and sends them straight to the provider.

Pros

  • Full styling control.

Cons

  • Card numbers exist in our page's DOM, readable by any script there.
  • Scope moves to SAQ A-EP, with many more requirements on our site.

Our servers receive card data

The form posts to our API, which calls the provider.

Pros

  • Total control.

Cons

  • Full PCI DSS scope (SAQ D or an on-site assessment). Almost never worth it for a store.

What we pick: Provider iframes. They give us a native-looking page while card numbers go from the frame straight to the provider. Our code only ever sees a token or a payment method id. The redirect stays as a fallback for methods that need it. We still treat the page that hosts the frames as sensitive: the security chapter covers why and what PCI DSS 4.0 asks of it.

The card data boundary. Our page on store.com holds the checkout app and, inside it, a provider iframe with card number, expiry, and CVC fields. Card data goes from the iframe straight to the provider API. The iframe sends only events and a token to the checkout app, which sends the token, never the card, to our BFF. The BFF confirms the payment with the provider using the amount and the token. A note says card data stays with the provider, and the page needs script rules.Our page: store.comCheckout appsections, summaryProvider iframeCard numberMM / YYCVCevents, tokenOur BFFsees a token onlytoken, never cardProvider APIgets card dataconfirmcards: providerpage: script rules
The frame keeps the card number out of our code and our servers. It does not protect the page around it: a script on store.com could still draw a fake form over the frame, which is why the page that hosts it needs script control.

Tokens and the payment intent

Modern providers model a payment as an object on their side, created by our server with the amount and currency (Stripe calls it a PaymentIntent; Adyen and Braintree have their own equivalents). The browser never decides the amount. Because the amount lives on the server-side object, a tampered page cannot pay 1.00 for an 84.10 order.

Our design confirms the payment on the server, which providers support for exactly this case (Stripe calls it finalizing payments on the server). On Pay, the provider script turns the card data inside its frame into a token (in Stripe, a ConfirmationToken). Our place-order call sends that token to our checkout backend (a backend for frontend, or BFF, that owns the session), which confirms the intent for the session total, getting back succeeded, requires_action, or a decline.

The simpler alternative, where the provider script confirms the intent from the browser with a client secret, works too, but then our server learns the outcome only afterwards.

3-D Secure: a challenge in a frame or a redirect

Under strong customer authentication (SCA) rules in the EU and UK, the bank may ask the shopper to prove it is them, through 3-D Secure (3DS). The Amazon guide explains the protocol. What matters for our design is that a challenge arrives as requires_action, and the provider script then runs it (Stripe's handleNextAction) in one of two shapes.

Most often the provider opens the bank's page in a modal iframe on our page, and the provider's promise resolves when it closes. Sometimes the bank's flow needs the whole page, so the browser is redirected to the bank and back to a return URL we provide. Redirect-based payment methods such as iDEAL always work this way.

The redirect is the reason the server session matters again. Our page unloads, and every piece of state in memory is gone. The return URL carries the checkout session id (/checkout/cs_123/return), so the page loads the session from the server, reads the payment and order status, and shows the right screen. In both shapes, the page then reads the session (GET /checkout-sessions/{id}) to learn the result, so a design that handles the redirect handles the modal too.

Sequence of a card payment with a 3-D Secure challenge, between the page, the BFF, the provider, and the bank. The page sends complete with version 8 and a key. The BFF confirms 84.10 with the provider, which replies requires action. The BFF replies 202, needs 3DS. The page runs the challenge through the provider script, which shows the bank's challenge. The bank replies approved. The page then reads the session, and the BFF replies completed with the order. If the bank declines instead, the session comes back open with a payment error, and the page shows a message and keeps the form.PageBFFProviderBankcomplete(v8, key)confirm 84.10requires_action202: needs 3DSopen challenge3DS challengeapprovedGET sessioncompleted: orderif declined: error shown, form kept
The challenge happens between the provider and the bank, inside a frame on our page or after a redirect. Either way, the page then reads the result from the server session, so a redirect that wipes memory changes nothing.

Saved cards

Signed-in shoppers see their saved cards as a radio list: brand, last 4 digits, expiry, with the default selected. The card itself is stored by the provider against a customer id; we store only the provider's payment method id and the display fields.

Expired cards show as disabled with an Update link. Some stores ask for the CVC again on a saved card for riskier orders; that CVC field is a provider iframe too. A new card goes through the same frames, with a Save for next time checkbox that is off by default for guests.

Declines: clear, kind, and never revealing

In Baymard's survey, about 1 in 10 shoppers who abandoned cited a declined card. A decline is not the end of the order, as long as the page tells the shopper what to do next. The provider returns a decline code; we map it to a message that helps without leaking fraud signals.

Provider says (Stripe-style code)We showThen
incorrect_cvc, incorrect_numberThe card number or security code looks wrong. Check them and try again.Focus the field in the frame; keep everything else filled.
expired_cardThis card has expired. Use another card.Offer saved cards and wallets.
insufficient_fundsYour card was declined. Try another card or payment method.Do not say why: the shopper may be next to someone.
authentication_requiredYour bank needs to confirm this payment.Start the 3DS flow again.
fraudulent, generic_decline, do_not_honorYour card was declined. Contact your bank or use another payment method.Never mention fraud; never let bots learn which check failed.
processing_error or a timeoutWe could not reach your bank. Nothing was charged. Try again.Only say nothing was charged if the server confirms it.

The message goes in a live region and the error summary, focus moves to it, and the form keeps every value. Repeated declines from one session (say, 5 in 10 minutes) are a card-testing signal: fraudsters use checkouts to test stolen card numbers. The server rate-limits by session, IP, and device, and the client shows a bot check when told to.

Check your understanding · Exercise 6

Which statements about provider card iframes are true? Select all that apply.

Placing the order: exactly one charge, whatever the network does

Everything so far was safe to repeat: re-pricing twice gives the same total. Placing the order is different. It reserves stock, takes money, and creates a record that a warehouse acts on. It must happen exactly once per checkout, and the shopper must always end up knowing whether it happened.

The naive version disables the button and sends POST /orders with the cart and payment token. Here is how it fails, with numbers from a sale day where the place-order call takes 2 to 8 seconds under load:

  1. Double tap. On a slow phone the second tap lands before React re-renders the disabled button. Two requests leave. Two orders.
  2. Impatient retry. After 8 seconds of spinner, the shopper refreshes and presses Place order again. The first request was still running. Two orders.
  3. Lost response. The server charged the card and created the order, then the train entered a tunnel. The browser gets a network error. The page says Something went wrong, try again. The shopper does. Two charges, and a support ticket.
  4. Two tabs. The shopper had checkout open in two tabs and placed the order in both. Two orders for one cart.

The third case is the important one, because no amount of button disabling fixes it: the client simply does not know the outcome. Any safe design must let the client ask again, or retry, without the risk of doing the work twice. The flight booking guide solves this for a booking with an idempotency key; checkout adds a second layer, the session, for tabs and refreshes.

Decision How do we make place-order safe to retry?

Disable the button

Set a submitting flag and disable the button until the response arrives.

Pros

  • Trivial, and still worth doing as the first layer.

Cons

  • Does nothing for refreshes, a second tab, a lost response, or a retry library.

An idempotency key per attempt

The client creates a random key (crypto.randomUUID()) when the shopper presses Place order, and sends it as an Idempotency-Key header on every try.

The server stores the key with the result; a repeat with the same key returns the stored result instead of running again.

Pros

  • Transport retries and lost responses become safe: retry with the same key, get the same answer.
  • A well-known pattern (Stripe's API works this way).

Cons

  • The key lives in one tab. A refresh that loses it, or a second tab, makes a new key and a new order.
  • Keys expire (often after 24 hours), and the server must handle two requests with the same key arriving at the same time.

The checkout session is the unit of exactly-once

Place-order is POST /checkout-sessions/{id}/complete with the session version the shopper saw.

The server moves the session from open to processing to completed exactly once, under a lock. Any later call for the same session returns the same order.

The client can always ask GET /checkout-sessions/{id} to learn the outcome.

Pros

  • One order per checkout session, across retries, refreshes, tabs, and devices.
  • The version check also catches price and stock changes (next chapter).
  • Recovery after a lost response is a simple read.

Cons

  • The session needs a proper state machine and a lock on the server.
  • Starting a second order for the same cart needs an explicit new session.

What we pick: Use the session as the exactly-once boundary, and also send an idempotency key. The session guarantees one order per checkout, which covers tabs and refreshes. The key makes each individual request safe to retry at the transport level and lets the server return the stored response quickly. The server, in turn, passes an idempotency key derived from the session id to the payment provider, so even its own retries to the provider cannot charge twice.

State machine of placing an order. Editing goes to submitting on Pay. Submitting goes to placed on 201. On 409, it goes to changed, which shows a panel and returns to editing. When 3-D Secure is needed it goes to requires action, which returns to submitting when the challenge passes, or to declined when it fails. Submitting also goes to declined on a 402 response. Declined shows a message, keeps the form, and returns to editing. On a timeout it goes to reconciling, which polls the session and goes to placed when the order is found.editingsubmittingplacedPay201changedpanel, then editing409requires action3DSpassedreconcilingpoll sessiontimeoutdeclined402 too; then editingfailed
Every outcome of the request has its own state, and the Pay button exists only in editing. A timeout leads to reconciling, never back to editing, so the shopper is never invited to pay a second time for an order that may exist.

On the client: a guard, a key, and bounded retries

The client side is small but every line matters. A synchronous guard (a ref, not React state, so it is set before the next tap is handled) stops double taps. The key is created once per press for a given session version and saved in sessionStorage, so a refresh during submit reuses it.

Network errors and 5xx responses are retried with the same key and backoff (see the retries and backoff lesson); 4xx responses are answers, not failures, and are never retried. After the retries run out, the client does not show an error. It switches to reconciling: it asks the server what happened.

ts
const submitting = useRef(false);

async function placeOrder(session: CheckoutSession) {
  if (submitting.current) return;
  submitting.current = true;
  const keyName = `place:${session.id}:${session.version}`;
  const key = sessionStorage.getItem(keyName) ?? crypto.randomUUID();
  sessionStorage.setItem(keyName, key);
  machine.send("SUBMIT");
  try {
    for (const delay of [0, 1000, 3000]) {
      await sleep(delay);
      try {
        const res = await api.complete(session.id, session.version, key);
        return machine.send({ type: "RESULT", res }); // 2xx or 4xx
      } catch {
        // network error or 5xx: same key, try again
      }
    }
    machine.send("UNKNOWN"); // starts polling GET /checkout-sessions/:id
  } finally {
    submitting.current = false;
  }
}
Place-order with a synchronous guard, a stored key, retries on transport errors only, and reconciliation at the end.

On the server: the order of steps

The frontend engineer does not build this, but must know its shape to design the client states. The complete call runs these steps in order, and each is safe to repeat:

  1. Look up the idempotency key. If a stored response exists, return it. If the same key is still running, return 409 with the error code request_in_progress and a hint to poll.
  2. Lock the session. If it is already completed, return the existing order. If it is processing, return 202 Accepted with the status URL.
  3. Check the version. If the shopper's version is older than the current one, return 409 with the current session.
  4. Re-validate: recompute prices, tax, promotions, and stock. If anything changed, bump the version and return 409 with a change list.
  5. Reserve stock, then confirm the payment with the provider, using an idempotency key derived from the session id. If the bank wants a 3-D Secure challenge, return 202 with what the page needs to run it.
  6. Create the order, mark the session completed, store the response under the key, and return 201 with the order id.

The confirmation page is the source of truth

On success, the client navigates to /orders/{id}/confirmation with history.replaceState semantics (the router's replace option), so the Back button does not return to a page that offers to place the order again. The confirmation page loads the order from the server, not from memory, so a refresh, a shared link, or a return from a 3DS redirect all show the same truth. If the shopper does go back to /checkout/cs_123, the session is completed and the page shows Your order is placed with a link to it, not a fresh form.

Try the lab below. Each scenario is a real failure. Compare the three strategies and count charges and orders.

Strategy
Failure

Disable button only, Response lost after charge: 2 charges. The shopper sees an error, then a double charge.

Disable button only with Response lost after charge. Steps: POST /orders, card charged, then response lost in a tunnel, then page: Something went wrong, then shopper pays again. Result: 2 charges; the shopper sees an error, then a double charge.1POST /orders, card charged2response lost in a tunnel3page: Something went wrong4shopper pays again2 orders2 chargescharged twice
A disabled button can miss a fast second tap that lands before the re-render, and does nothing for the other failures. A key in memory makes retries safe but dies with a refresh and differs per tab. The session ties every attempt to one order.

Check your understanding · Exercise 7

The place-order request times out after 15 seconds. The server may or may not have charged the card. What should the client do? Select all that apply.

When things change or break: re-validation and reconciliation

Two kinds of trouble arrive at the last moment. The world changed while the shopper was typing (a price, a stock level, a code). Or the place-order call itself ended without an answer. Both need a screen that is honest, calm, and never asks the shopper to pay twice.

Re-validation at submit: show what changed and ask

Prices and stock on the page are a snapshot from the last re-pricing, maybe 20 minutes ago. The server re-validates everything inside the complete call (step 4 in the previous chapter). If anything differs, it does not charge. It bumps the session version and returns 409 Conflict with the new session and a list of changes.

json
{
  "error": "session_changed",
  "session": { "id": "cs_123", "version": 9, "total": { "amount": 9130, "currency": "EUR" } },
  "changes": [
    { "kind": "price", "lineId": "li_2", "from": 2990, "to": 3490 },
    { "kind": "stock", "lineId": "li_3", "requested": 2, "available": 1 },
    { "kind": "promotion", "code": "SPRING", "reason": "expired" }
  ]
}
The 409 body lists each change in a form the UI can render line by line.

The UI turns that into a short panel above the button: Before you pay, 3 things changed. Each change gets one line in plain words (Linen shirt is now 34.90, was 29.90; Only 1 Wool scarf left, we updated the quantity; Code SPRING has ended), followed by the new total on the button: Pay 91.30 EUR. Focus moves to the panel heading and a live region announces it. The shopper must press the button again; nothing is charged at a price they did not see.

ChangeRuleWhy
Price went upNever accept silently. Show old and new, require a new press.Charging more than shown is a trust and legal problem.
Price went downApply it and mention it.Good news still deserves a line; the total on the button changed.
Quantity partly availableReduce to what is left, show it, require a new press.The shopper may not want 1 of 2.
Item sold outRemove it, show it with a link to similar items. If nothing is left, go back to the cart.An order with nothing in it is not an order.
Code no longer validKeep it visible with the reason; recompute.The shopper may want to pick a different code or leave.
Delivery option gonePick the cheapest valid one, show it, require a new press.Delivery dates may matter more than price.

Payment went through, the response did not

This is the failure that makes checkout a system design question. The server charged the card and created the order, but the response was lost. The client's retries also time out because the phone is offline. What does the shopper see, and how does the order still get completed if they close the tab?

Sequence of a lost response between the page, the BFF, and the provider. The page sends complete with version 8 and key k1. The BFF confirms with the provider using a key derived from the session, and the provider replies succeeded. The BFF's 201 reply is lost on the way. The page's retry with k1 times out. The page shows Confirming your order and polls the session. Meanwhile the provider's webhook tells the BFF the payment succeeded. The BFF answers the poll with completed and the order id, and the page shows the confirmation.PageBFFProvidercomplete(v8, k1)confirm, key cs_123succeeded201: lost on the wayretry k1: timeoutGET session (poll)webhook: succeededcompleted: ord_88412page meanwhile: Confirming your order, no Pay button
The page never shows an error for an unknown outcome. It polls GET /checkout-sessions/cs_123 with backoff, and the webhook completes the order on the server even if the page is closed.

Three mechanisms work together, one on each side.

  • The client polls the session. After retries run out, the page shows Confirming your order. Please do not pay again. It polls GET /checkout-sessions/{id} with backoff (1, 2, 4, 8 seconds, capped at 10, for up to 2 minutes), and resumes immediately when the browser fires the online event or the tab becomes visible again. The answer is one of completed (go to the confirmation), processing (keep waiting), or open with a payment error (show it and let them try again).
  • The provider webhook completes the order on the server. The payment provider sends a webhook (a server-to-server HTTP call) to our backend: in Stripe, payment_intent.succeeded, or payment_intent.amount_capturable_updated when the card is only authorized. If the session is still processing because our own request died halfway, the webhook handler finishes it: creates the order and sends the confirmation email. The order no longer depends on the browser staying open. Webhooks can arrive late, twice, or out of order, so the handler is idempotent too (see the webhooks lesson).
  • A sweeper resolves the stuck ones. A scheduled job asks the provider about any session stuck in processing for more than a few minutes. If the payment succeeded, it completes the order; if the order cannot be created (the stock is gone), it voids the authorization or refunds and emails the shopper.

A pending state, not an error

The wording of the unknown state decides whether the shopper pays twice. Something went wrong, please try again invites a second payment. A calm message does not: Confirming your order, this can take up to a minute, we will email a.shopper@example.com when it is confirmed, please do not pay again. The place-order button is hidden in this state, not just disabled.

If two minutes pass with no answer, the page says the order is still being confirmed and that the email will follow, with a link to support, still without a Pay button. Other tabs with the same session hear about the result over a BroadcastChannel and switch to the same state.

Expiry and time limits

A checkout session expires after some hours (say 24) so stale prices cannot be charged days later. Most stores do not hold stock during checkout. For flash sales that do (a 10-minute reservation, like a fare hold in the flight booking guide), the timer must follow WCAG's timing rules: show it, warn at least 20 seconds before it ends, and let the shopper extend it with one action. An expired session opens a fresh one from the same cart and shows what changed, the same panel as above.

Check your understanding · Exercise 8

A shopper's payment succeeded, but their phone lost signal and they closed the tab. What makes sure they get their order? Select all that apply.

Architecture, data model, and interface

With the decisions made, the parts fall out of them. The checkout is its own route with its own small bundle. It talks to one backend for frontend (BFF) that owns the checkout session, and it hosts the payment provider's frames, which talk to the provider directly.

Architecture of the checkout. In the browser: Sections send edits to the Session client, which talks HTTP to the Checkout BFF. The Session client writes the priced session, version 8, into the Session store. The store feeds the Checkout machine and the Order summary. The Payment adapter mounts the Provider iframe. On the servers: the Checkout BFF calls Pricing and tax, a Webhooks handler receives events from the Provider, and the Provider receives card data only from its iframe.Browser: checkout routeSectionscontact to reviewSession clientabort, retry, polleditCheckout machinewhich buttonsSession storepriced session v8Order summarytotal, Pay buttonPayment adapterframes, 3DSProvider iframeServersCheckout BFFowns the sessionPricing, taxstock, promosWebhookscomplete ordersProvider
Views never compute money: they render the store, and the store only changes when the session client accepts a newer version. Card data flows from the provider frame to the provider and nowhere else.
PartResponsibilityWhy the line is here
Checkout route (server-rendered shell)Renders the page with the session already priced: summary, sections, express buttons.The first paint shows real totals; no spinner waterfall on a phone.
Section componentsContact, Address, Delivery, Payment, Review. Each owns its form state and validation.Each can be tested alone and reused (the address form also lives in account settings).
Session storeThe last priced session from the server, its version, and the pending flags.One place holds the server truth; sections read from it, never compute money.
Session clientAll calls to the BFF: patch with abort and version checks, complete with a key and retries, poll.Race and retry rules live in one module, not in every component.
Checkout state machineediting, submitting, requires action, changed, declined, reconciling, placed.Illegal states (a Pay button during reconciling) become impossible.
Payment adapterWraps the provider SDK: mount frames, confirm, handle 3DS, map decline codes.Swapping or adding a provider touches one module.
Address service adapterSuggestions and validation behind one interface.Places, Loqate, or a carrier API can change without touching the form.
Checkout BFFOwns the session, calls pricing, tax, promotions, inventory, the provider, and orders.The browser makes one call per edit instead of five; secrets and rules stay server-side.
Webhook handler and sweeperComplete sessions from provider events; resolve stuck ones.Orders complete even if the browser is gone.

Three boundaries deserve a sentence each in an interview. The money boundary: the browser never computes or decides an amount; it renders what the BFF returns and confirms intents the BFF created. The card boundary: card data exists only inside the provider's frames and the provider's servers. The truth boundary: the session on the server is the truth; the client store is a cache of it with a version, rebuilt from scratch after a refresh or a 3DS redirect.

Data flow of the checkout. Pricing and tax send totals to the Checkout BFF. The BFF gives the session at version 7 to the SSR route, which sends HTML and JSON to the browser, filling the Session store, which renders the sections and summary. The shopper's edits and the Pay press go to the Session client, which sends PATCH and complete calls to the BFF, and puts the returned version 8 into the store, replacing version 7. Separately, the Payment frames send card data to the Provider, and the Provider sends a webhook to the BFF.Pricing, taxstock, promosCheckout BFFsession v7, v8totalsSSR routeHTML + sessionsession v7Session storepriced, versionedHTML, JSONSections, summarySession clientabort, retry, polledit, PayPATCH, completev8 replaces v7Payment framesProvidercards, 3DScard datawebhook
Reads go down the left: server-rendered, already priced. Writes go up the right: small patches, each answered with a whole new version. The card data path never touches the BFF.

Rendering and loading

The checkout is a separate route with its own entry, not a modal inside the store's single-page app. That keeps its JavaScript small and lets us run a stricter Content Security Policy (CSP) on it than on the rest of the store. The server renders the shell with the priced session embedded, so the summary and the express buttons are visible at first paint. The loading order (provider script early, address suggestions late) is in the performance section.

Think first

Product asks to make checkout a slide-over panel inside the product page, to feel faster. What do you say?

Data model: what the client holds, and where

The client holds little, and almost all of it is a copy of the server session. The truth boundary shapes the model: money and availability come from the server; the client owns only what the shopper is typing right now and the status of its own requests.

ts
type Money = { amount: number; currency: string }; // minor units

type CheckoutSession = {
  id: string;
  version: number;
  status: "open" | "processing" | "completed" | "expired";
  email?: string;
  lines: { id: string; sku: string; title: string; qty: number; unit: Money; total: Money }[];
  shippingAddress?: Address;
  billingSameAsShipping: boolean;
  deliveryOptions: { id: string; label: string; price: Money; arrives: [string, string] }[];
  selectedDeliveryId?: string;
  codes: { code: string; applied: boolean; reason?: string; amount?: Money }[];
  giftCards: { last4: string; applied: Money }[];
  totals: {
    subtotal: Money; discount: Money; delivery: Money;
    tax: Money; taxIncluded: boolean; taxEstimated: boolean;
    giftCards: Money; due: Money;
  };
  payment: { intentClientSecret?: string; status?: string; declineCode?: string };
  orderId?: string;
  expiresAt: string;
};
The priced session as the BFF returns it. The client never edits these fields directly; it sends patches and replaces the whole object.
Data model sketch. On the server, the CheckoutSession with id, version 8, and status holds lines, address, delivery, codes, totals, and payment, and creates one Order. In the browser: the Session store holds a copy of version 8, field drafts live in component state, the open section lives in the URL, the place-order key lives in sessionStorage, and card data lives only in the provider frames.Server: the truthCheckoutSessionid, version 8, statuslinesaddressdeliverycodestotalspaymentOrdercreated onceBrowserSession storecopy of v8Field draftscomponent stateURL?section=paymentsessionStorageplace-order keyProvider frames: cards
The server holds the truth; the browser holds a versioned copy, what is being typed, and where the shopper is. Card data has its own box that our code cannot open.
DataWhere it livesWhy there
Priced sessionSession store in memory, filled from the server-rendered pageA cache of server truth, replaced whole on each response with a higher version.
Field drafts (what is typed but not yet sent)Section component state (form library)Changes on every keystroke; no one else needs it until blur or submit.
Which section is openURL query ?section=paymentRefresh and Back keep the place. See the URL as state lesson.
Pending flags and errors per callSession storeThe summary and the button both need them.
Checkout machine stateA small state machine next to the storeDecides which buttons exist at all.
Idempotency key for the current presssessionStorage, keyed by session id and versionSurvives a refresh during submit; scoped to the tab.
Checkout session idAn httpOnly cookie, and in the URL pathThe cookie finds it from the cart; the path makes the return from 3DS work.
Card dataOnly inside the provider framesNever in our state, storage, logs, or analytics.
Saved addresses and cards (signed in)Server, included in the session responseWe show display fields only: brand, last 4, expiry.

Notice what is missing: no local copy of prices to compute with, no cart in localStorage that could disagree with the session, no card fields in form state. Autosaving drafts is still useful for the address, but the right place for it is the server session (sent on blur), not localStorage with personal data in it; the multi-step form guide explains autosave in depth.

Check your understanding · Exercise 9

Which pieces of checkout data should never be written to localStorage? Select all that apply.

Interface: the checkout API

The API is small and resource-shaped: one session resource with a few actions. Every write returns the full priced session, so the client never has to merge partial responses.

CallWhat it doesReturns
POST /checkout-sessionsCreate from the current cart (or adopt an open one).201 with the priced session
GET /checkout-sessions/{id}Read the session and its status. Used for refresh, 3DS return, and reconciliation.200 with the session
PATCH /checkout-sessions/{id}Set email, address, delivery option, billing address. Body includes expectedVersion.200 with the new session, or 409 if the version is old
POST /checkout-sessions/{id}/codesApply a discount code or gift card.200 with the session; 422 with a reason if invalid; 429 if rate-limited
DELETE /checkout-sessions/{id}/codes/{code}Remove a code.200 with the session
POST /checkout-sessions/{id}/completePlace the order. Idempotency-Key header, body has expectedVersion and the payment method token.201 order, 202 processing or a 3DS challenge to run, 402 declined, 409 changed or still running, 422 invalid
POST /webhooks/paymentsProvider to BFF only. Signed, idempotent.200
http
POST /checkout-sessions/cs_123/complete
Idempotency-Key: 6f1c2a9e-4b7d-4e55-9a51-0c3e8f2d7b10
Content-Type: application/json
X-CSRF-Token: 8d0e...

{ "expectedVersion": 8, "paymentMethod": "pm_1Q...", "expectedTotal": { "amount": 8410, "currency": "EUR" } }

HTTP/1.1 201 Created
Content-Type: application/json

{ "orderId": "ord_88412", "status": "completed", "confirmationUrl": "/orders/ord_88412/confirmation" }
Placing the order. The same key and body are sent on every retry of this press.

The request sends expectedTotal as well as the version. The version alone is enough for the server to detect change, but the total makes the intent explicit in logs and lets the server refuse if the client rendered a different number for any reason. Errors use one shape across all calls: a machine-readable error code, a human message that is safe to show, field paths for validation errors, and the current session when relevant (see the API contracts and errors lesson).

json
{
  "error": "payment_declined",
  "declineCode": "insufficient_funds",
  "message": "Your card was declined.",
  "retryable": true,
  "session": { "id": "cs_123", "version": 8, "status": "open" }
}
A decline. The client maps the code to its own message and keeps the form as it was.

Component interfaces

tsx
type SectionProps = {
  session: CheckoutSession;
  pending: boolean;
  open: boolean;
  onOpen: () => void;
  onPatch: (patch: SessionPatch) => Promise<void>;
};

<AddressSection {...common} formats={formats} suggest={addressService} />
<DeliverySection {...common} />
<PaymentSection {...common} adapter={paymentAdapter} savedCards={user?.cards} />
<OrderSummary session={session} pending={pending} />
<PlaceOrderButton total={session.totals.due} state={machine.state} onPress={placeOrder} />
Sections are dumb about money: they receive the session and emit patches.

Check your understanding · Exercise 10

Which status codes from the complete call should the client retry automatically with the same key? Select all that apply.

Security: forged requests, script control, and web skimming

Checkout is the most attacked page of a store, for an obvious reason: it is where the money and card numbers are. Three threats shape the frontend: requests forged from other sites, scripts that should not run on the page, and skimmers that steal what shoppers type.

Forged requests (CSRF)

The checkout session lives in a cookie, so a malicious site could try to make the shopper's browser send POST /checkout-sessions/cs_123/complete with that cookie attached. The CSRF lesson covers the defences in depth; for checkout we use all three cheap layers.

The session cookie is SameSite=Lax (or Strict), Secure, and HttpOnly. Every state-changing call carries a CSRF token from the server-rendered page in a header, which a cross-site form cannot set. And the BFF checks the Origin header on writes. The 3DS redirect return arrives as a top-level GET, which Lax cookies allow, so the return page works; it only reads state and never places orders.

Web skimming (Magecart) and why the iframe is not enough

Web skimming, often called Magecart after the groups that made it famous, is malicious JavaScript added to a checkout page that copies what shoppers type and sends it to the attacker. The attacker does not need to break into the store's servers: changing one third-party script the page loads is enough.

In the 2018 British Airways breach, attackers changed a JavaScript library served from BA's own site and skimmed the card and personal details of up to about 429,000 customers and staff over about two weeks; the UK regulator later fined the airline 20 million pounds.

Provider iframes protect the card numbers from being read directly, but a script on our page can still hide the real frame and draw a fake card form on top, or skim the name, email, and address fields, which are on our page. So the page that hosts the payment frames must run only scripts we know about. That is exactly what PCI DSS 4.0 added in two requirements, best practice until 31 March 2025 and mandatory since.

RequirementWhat it asksHow a frontend team meets it
6.4.3: manage payment page scriptsEvery script that runs on the payment page is authorized, its integrity is assured, and an inventory lists each one with a written reason.A short, reviewed script list for the checkout route; a CSP that allows only those origins; Subresource Integrity (integrity hashes) on scripts we host or pin.
11.6.1: detect changes to the payment pageA mechanism alerts on unauthorized changes to the payment page's HTTP headers and script contents as the browser receives them, run at least weekly or as often as a risk analysis says.CSP violation reports sent to an endpoint we watch, plus a monitor (in-house or a vendor) that loads the page and compares scripts and security headers against the approved list.

As covered in the payment chapter, a merchant that only embeds provider frames may be able to complete SAQ A, where these two items are replaced by the eligibility criterion about script attacks. The PCI SSC's FAQ 1588 allows two ways to confirm it: script controls like those in 6.4.3 and 11.6.1, run by the merchant or a third party, or a confirmation from the provider that its embedded form protects the page when integrated as instructed. Either way, the engineering is the same: a short, known script list is what makes the claim true.

A strict Content Security Policy for the checkout route

The CSP lesson explains the directives; here is what is specific to checkout. The policy is stricter than the store's and lists the payment provider's and the address service's origins explicitly. Use nonces for our own scripts and keep the list of allowed origins short.

If you add strict-dynamic so that a trusted loader can add scripts, browsers that support it ignore the origin list, and the nonce becomes the control. Provider docs publish the exact origins they need; the example uses the ones Stripe lists for Stripe.js, including hooks.stripe.com for 3DS and redirect frames.

http
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m' https://js.stripe.com https://*.js.stripe.com https://maps.googleapis.com;
  frame-src https://js.stripe.com https://*.js.stripe.com https://hooks.stripe.com;
  connect-src 'self' https://api.stripe.com https://maps.googleapis.com https://places.googleapis.com;
  img-src 'self' data: https://*.stripe.com https://maps.gstatic.com;
  frame-ancestors 'none';
  form-action 'self';
  base-uri 'none';
  report-to csp-endpoint
A checkout CSP with Stripe and Google Maps origins. Our own scripts carry a per-response nonce.
  • frame-ancestors 'none' stops other sites from framing the checkout to trick clicks (clickjacking).
  • form-action 'self' stops an injected form from posting to an attacker's server.
  • No tag managers, chat widgets, heatmaps, or ad pixels on the checkout route. Each one is a script that can change the page. Purchase events are sent from our own code or from the server (next chapter).
  • Provider scripts update in place at a fixed URL, so they cannot carry a fixed SRI hash. Allow them by origin, keep them in the inventory, and let the change monitor watch them.
  • report-to names an endpoint declared in a Reporting-Endpoints response header, where the browser sends violation reports for the change monitor to read.

Two smaller items. Shopper input (names, address lines, gift messages) is rendered as text, never as HTML, so a name like an image tag with an onerror handler stays harmless; see the XSS lesson. And code and gift card endpoints are rate-limited per session, IP, and device, because they are guessing targets.

Web skimming, before and after. On the open page, a tag manager loads skimmer.js, which draws a fake card form over the real one and sends typed data out. On the locked-down page, only our nonce scripts and the provider iframe run; skimmer.js is blocked by the Content Security Policy and the block is reported.open pageAddress fieldsFake card formtag managerskimmer.jstyped data sent outlocked downAddress fieldsProvider iframeour codeskimmer.jsblocked by CSP, reportedany script can change the pageonly listed scripts run
An iframe protects only what is inside it. The defence for the page around it is a short, known script list, enforced by CSP and watched by change detection.

Check your understanding · Exercise 11

The marketing team wants to add a tag manager to the checkout page to track purchases. What is the strongest answer? Select all that apply.

Speed, accessibility, and measuring the funnel

Three qualities decide whether a correct checkout is also a good one: it loads fast on a mid-range phone, everyone can complete it, and the team can see where people give up without collecting their personal data.

Performance: a small page with one heavy guest

  • Budget. Our own JavaScript for the route stays under about 150 KB compressed. The provider script is the largest single item and is not optional, so everything else gives way to it: no carousels, no recommendation widgets, no chat.
  • First paint shows real numbers. The server renders the priced session, so LCP (Largest Contentful Paint) is the summary or the first section, not a spinner. Target LCP under 2.5 s at the 75th percentile on mobile.
  • Connect early. The provider script is on the critical path for the Pay button, so it loads early, and preconnect hints to the provider's script and API origins in the document head do the DNS lookup and the TCP and TLS handshakes ahead of time (often a few hundred milliseconds on mobile).
  • Load late what is late. The address suggestion script loads on focus of the address field. The 3DS code is inside the provider SDK and loads only when needed.
  • Keep taps fast. INP under 200 ms: a tap updates the selected option right away and the re-pricing call runs in the background, and there are no long tasks from third-party scripts, because the route has none.
  • No layout jumps. Payment frames and delivery options get reserved heights so they do not push the Pay button as they load (CLS, Cumulative Layout Shift, under 0.1). A Pay button that moves under a thumb causes mis-taps.

Accessibility: errors, focus, and time

The multi-step form guide covers the error summary and inline errors in depth; checkout adds a few specific points.

  • Error summary first. On a failed submit, a summary at the top of the open section lists each error as a link to its field, and receives focus. For errors inside provider frames, the provider's own error text appears inside the frame, and our summary links to the frame's container.
  • Announce money changes. When the total changes after re-pricing, a polite live region says Total updated to 84.10 euros. It is debounced so a burst of changes is announced once. The change panel at submit is announced and focused.
  • Focus on section changes. When a section completes and the next opens, focus moves to the new section's heading. The collapsed summary keeps an Edit button that names what it edits (Edit shipping address).
  • Busy, not frozen. While placing the order the button shows a progress label (Placing order) and has aria-disabled plus a guard in the handler, so screen reader users still hear what is happening. During reconciling, a status message explains the wait.
  • Time limits. No hidden session timeouts. If a reservation timer exists, follow WCAG 2.2.1: warn before it ends and allow extending it with one action.
  • Wallets and frames. Wallet sheets and provider fields are accessible by their makers, but test them with VoiceOver and TalkBack in your page, with your labels. A visible label above each frame (Card number) is still ours to provide when the provider asks for it.

Measuring the funnel without leaking personal data

The business wants to know where shoppers give up: how many start checkout, finish each section, see a decline, and place an order. The analytics tracking lesson covers event design and delivery. The checkout-specific rules are about privacy and trust.

EventProperties we sendNever send
checkout_startedsession id (random), cart value band, item count, device type, express buttons shownEmail, user id from third-party cookies
section_completedsection name, time in section, number of validation errors by field nameField values: names, addresses, postal codes
reprice_failed, place_order_errorerror code, HTTP status, retry count, durationRequest or response bodies
payment_declineddecline category (card details, funds, authentication, other)Card brand plus last 4 together with the order, decline reason text
order_placedorder id, value, currency, delivery option id, payment method typeAnything from inside the payment frames

Events go from our own code to our own collection endpoint (first-party), batched and sent with navigator.sendBeacon or fetch with keepalive so the last event before navigation is not lost. The purchase event that ad platforms need is sent server-side from the order system, not from a pixel on the payment page. URLs in events are scrubbed of query parameters, because return URLs from payment redirects can carry tokens.

With this, a funnel chart shows, for example, that 31 percent of mobile shoppers who open the payment section never finish it, and the error events show whether that is declines, frame load failures, or something else.

Check your understanding · Exercise 12

Which analytics practices are right for a checkout? Select all that apply.

Running it in the interview

A checkout question rewards candidates who find the dangerous parts early. Many spend 30 minutes on form fields and run out of time before the place-order request, which is where most of the senior signal is. Here is a plan for 60 minutes.

MinutesWhat to doWhat to say out loud
0 to 6Scope questions: hosted or embedded payment, guests, countries and tax, wallets, traffic peaks, 3DS.The total depends on the address, so pricing must be server-side. Placing the order must be exactly once.
6 to 12Sketch the page: express buttons, sections, sticky summary, Pay button with the amount.Layout matters less than field count; I will keep fields few and the total always visible.
12 to 22Architecture: route, sections, session store, session client, payment adapter, BFF, provider, webhook.The server session with a version is the center; the client is a cache of it.
22 to 37Deep dive on place-order: double tap, lost response, idempotency key, session state machine, reconciliation, confirmation page.A timeout is not a failure. The client asks the session, the webhook finishes the order.
37 to 47Payment boundary and security: provider iframes, PCI scope, 3DS in a frame or redirect, CSP, no third-party scripts, script inventory.The iframe protects card numbers, not the page. The page must run only scripts we know.
47 to 55Re-pricing races, changes at submit, discounts and gift cards, address formats.Never charge an amount the shopper did not see.
55 to 60Performance, accessibility, funnel analytics; then questions.Measure drop-off per section with no personal data.

Draw the place-order sequence with the failure path on the board. It is the single picture that shows you understand distributed systems from the browser's side: the request, the charge, the lost response, the retry with the same key, the poll, and the webhook arriving at the server independently.

Follow-up questions and strong answers

Think first

The shopper opens checkout in two tabs and presses Pay in both within a second. What happens?

Think first

When would you choose a hosted checkout page instead of building this?

Think first

A flash sale ends while 3,000 shoppers are on the review section. What happens on the client and server?

Check your understanding · Exercise 13

You have 10 minutes left and have not covered place-order yet. What do you prioritize? Select all that apply.

Summary

  • Checkout centers on a server-side session with a version. The browser sends small edits and renders the priced session it gets back; it never computes money.
  • Layout (one page, steps, or collapsible sections) matters less than the number of fields, an always-visible total, and no surprise costs. Show the amount on the Pay button.
  • Guest checkout by default, account creation after purchase, and express wallets at the top remove most typing. Wallet sheets must open inside the tap, so price the session first.
  • Card fields live in provider iframes so card data never reaches our code. The amount comes from a server-side payment intent. Design for 3DS redirects by rebuilding state from the session.
  • Place-order is exactly once through two layers: an idempotency key per press for safe retries, and a session state machine for one order per checkout across tabs and refreshes.
  • A timeout is an unknown, not a failure: show a pending state, poll the session, and let the provider webhook and a sweeper complete orders even when the browser is gone.
  • Re-validate at submit and show what changed. Never charge an amount the shopper did not see.
  • The iframe protects the card number, not the page. Keep the checkout route free of third-party scripts, lock it down with CSP, and keep a script inventory with change detection.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next