Design an e-commerce cart and checkout
A cart that works for guests and signed-in shoppers, stays the same across tabs and devices, and feeds a multi-step checkout that takes payment exactly once. The hard parts are deciding who owns the cart, merging carts at sign-in, quantity changes that race each other, prices and stock that change under the shopper, and a payment step that must never charge twice.
The question
Design the cart and checkout for the website of a large online store. Shoppers add products from product pages and search results, change quantities, and apply promo codes. They can shop as guests or sign in, and they often switch between their phone and laptop before buying.
Checkout collects an address, a shipping method, and a payment, then places the order. Walk me through the client: where the cart lives, how it stays correct, and how you make sure nobody is ever charged twice.
Real-life examples: Amazon cart and checkout, Shopify checkout, Etsy cart, Zalando checkout, Target cart and checkout
Requirements: what to ask before drawing anything
A cart looks like a list with a total at the bottom. In an interview it's a small distributed system: a list that lives on the server, is edited from many tabs and devices, holds prices the shopper doesn't control, and ends in a payment that moves real money. You have about 45 minutes. Spend the first five asking the questions whose answers change the design, and say out loud what you assume when the interviewer shrugs.
Ask about who shops (guests or accounts), where they shop (one device or several), what the cart can hold, who sets prices and stock, how payment works, and which countries you sell in. The table gives the answer this guide assumes for each one, and which part of the design moves if the answer is different.
| Question | Answer we assume | What it changes |
|---|---|---|
| Can people buy without an account? | Yes. About half of orders are guest orders. | The cart needs an owner before sign-in, and a merge at sign-in. |
| Do shoppers switch devices? | Often: browse on the phone, buy on the laptop. | The cart must live on the server, not only in the browser. |
| How big is a cart? | Usually 1 to 5 lines. We support up to 100 lines, and up to 10 of one item. | Small enough to send the whole cart in every response. |
| Who sets prices, stock, and discounts? | Back-end services. Prices can change during a sale; stock runs out. | The client shows numbers; the server decides them. |
| How do people pay? | Cards through a payment provider, wallets (Apple Pay, Google Pay), and PayPal. | Hosted card fields, a redirect flow, and 3-D Secure. |
| Which countries? | 12 countries, 8 currencies, prices shown with or without tax by country. | Money formatting, tax display, and address forms per country. |
| How busy does it get? | 2 million visits a day, 10 times that on sale days. | Add to cart must be cheap; checkout must survive retries. |
Functional requirements
- Add a product variant (a specific size and color, called a SKU, stock keeping unit) to the cart from a product page or a search result.
- See the cart in a side drawer right after adding, and on a full cart page: lines, quantities, prices, discounts, and an estimated total.
- Change quantities, remove lines, save lines for later, and apply or remove a promo code.
- Keep one cart per shopper across tabs and devices. A guest cart survives a browser restart and joins the account cart at sign-in.
- Check out in steps: shipping address, shipping method, payment, review. Progress survives a reload.
- Pay by card (with 3-D Secure when the bank asks), by wallet, or by PayPal, and place the order exactly once.
- Show a confirmation page with the order number that is safe to reload and to bookmark.
Non-functional requirements, with numbers
Non-functional requirements are the qualities the feature must have. Give each one a number, measured at the 75th percentile of real visits, because a number can be checked and the word "fast" can't.
- Instant feedback. Pressing "Add to cart" updates the page within 100 ms, well inside the 200 ms that INP counts as good. INP, Interaction to Next Paint, is the Core Web Vital for how fast a page answers taps and clicks.
- Correct money. The amount charged always equals the total on the review screen the shopper confirmed. If a price changes, the shopper sees and accepts it before paying.
- Exactly-once orders. A double click, a retry after a timeout, or a reload during payment never creates two orders or two charges.
- Small checkout scope for card data. Card numbers never touch our servers or our JavaScript, which keeps the store in the smallest PCI DSS compliance scope (PCI DSS is the card industry's security standard).
- Fast checkout load. Checkout's largest content paints within 2.5 s (a good LCP, Largest Contentful Paint) on a mid-range phone over 4G, while carrying the payment provider's script.
- Accessible. Every step works with a keyboard and a screen reader, meets WCAG 2.2 AA, and announces cart changes and errors.
Out of scope, and why
- The product page and search: they only need an "add to cart" call and a cart count.
- Tax and shipping rate engines: they're back-end services. The client asks for a quote and shows it.
- Fraud scoring, inventory reservation, and order fulfilment: server work. We only handle the answers they send back.
- Subscriptions and split shipments: worth one sentence if asked, but they don't change the core flow.
Check your understanding · Exercise 1
Which answers from the interviewer would change the design the most (select all that apply)? Select all that apply.
What makes a cart and checkout hard
Follow one shopper for a week. On Monday, Priya adds trail shoes and two pairs of socks on her phone, as a guest. On Wednesday she signs in on her laptop, where her account cart already holds a desk lamp from last month.
On Thursday the shoes drop from $140 to $119 and the lamp sells out. On Friday she checks out, her bank asks her to approve the payment in its app, and her train goes into a tunnel just as she taps "Place order". Every chapter of this guide exists because of something in that week.
Five properties make this problem harder than it looks. Name them early in the interview, because each one rules out a naive design.
- Many writers, one cart. The same cart is changed from two tabs, a phone, a laptop, the sign-in merge, and the server itself when prices and stock move. A cart that only one screen edits is easy; this one is shared state.
- The money isn't ours to compute. Prices, discounts, tax, and shipping come from back-end services and change on their own schedule. The browser can show a number, but it can never be the one that decides what is charged.
- Fast clicks meet slow networks. Shoppers press + four times in a second. A phone on a train has 300 ms or more of round trip, and replies can arrive out of order. The page must feel instant and still end up correct.
- Checkout is a long, stateful flow. Four steps, each with its own form and server calls, that must survive a reload, a back button, a sign-in in the middle, and a trip to a bank page or PayPal and back.
- Payment can't be undone, and its outcome can be unknown. If the connection drops after "Place order", the charge may or may not have happened. A naive retry charges twice; a naive error message makes the shopper pay again by hand.
There's also a business shape to it. A cart is the top of a funnel: product page, cart, each checkout step, order. The Baymard Institute puts the average share of abandoned carts at about 70%, and many of the reasons shoppers give are design problems: forced account creation, a long checkout, surprise costs at the end, form errors. A design that is correct but makes the shopper wait, retype, or guess loses money every day.
| Property | What it forces | Where we handle it |
|---|---|---|
| Many writers | A server-owned cart, a version number, a merge rule, tab and device sync | Where the cart lives, Merging carts, Tabs and devices |
| Money is the server's | Server pricing on every read, a final check before paying, integer cents | Prices, stock, and promotions |
| Fast clicks, slow network | Optimistic quantities, one request in flight per line, dropping stale replies | Quantity changes that race |
| Long stateful flow | A state machine, a server checkout session, a URL per step | Checkout as a state machine |
| Irreversible payment | Hosted card fields, idempotency keys, a status to poll when the outcome is unknown | Payment, Placing the order |
Think first
Rank these failures from worst to least bad: the cart badge shows 2 for a second when the cart has 3; a sold-out lamp is silently removed; the shopper is charged twice.
The double charge is by far the worst: real money, a support call, a refund that takes days, and lost trust. The silent removal is next: nothing is charged wrongly, but the shopper thinks the site lost their items. The briefly wrong badge is the least bad, as long as it settles on the right number.
So be relaxed about brief staleness, careful about anything that changes without explanation, and paranoid about anything that moves money.
Check your understanding · Exercise 2
Which of these are real reasons a cart is harder than a simple list (select all that apply)? Select all that apply.
Where the cart lives: server, browser, or both
The first decision shapes everything after it: who owns the cart. The naive answer is the browser. Put the lines in localStorage, render the cart from there, and send the whole list to the server at checkout.
It's fast, works offline, and needs no database. It also breaks on Priya's Wednesday: her laptop has never seen her phone's localStorage, so the shoes and socks are simply not there.
It breaks in quieter ways too. The browser holds Monday's price, so every page must re-price the cart. Two tabs write the same key and the last one wins. The store can't send a "you left something in your cart" email. And the merge at sign-in runs in a tab that can close halfway.
The opposite naive answer, a server cart only for signed-in users, forces guests to make an account before adding a single item, one of the top reasons shoppers leave.
Decision Where should the cart, including a guest's cart, live?
What we pick: Server cart for everyone. The cross-device requirement and the rule that the server decides money both point there, and one code path halves the bugs. The two costs are manageable: the add-to-cart request is hidden behind an optimistic update, and guest carts are created only on the first add (never on a page view) and expire after 30 days without activity. The browser keeps a small copy for speed, never the truth.
GET /api/cart answers. Prices and lines live in the database, and the page shows the server's copy.Four places, one job each
- The cart database is the truth: lines, quantities, the price at the time each line was added, and a
versionnumber that goes up by one on every change. - The
cart_idcookie names a guest's cart. Set it withHttpOnlyso injected scripts can't read it,Secureso it only travels over HTTPS,SameSite=Laxso another site can't post to the cart with it, andMax-Age=2592000(30 days). The id is 128 random bits, because anyone holding it holds the cart. As a strictly necessary cookie, it usually needs no consent, but check with your privacy team. - The cart store in memory drives the page: the last cart the server confirmed, plus any changes the shopper made that the server hasn't confirmed yet (chapter 5).
- A snapshot in
localStorageholds only the item count, the cart version, and an owner tag. It never holds prices or lines.
Why keep a snapshot at all? Because product and search pages are cached at a CDN (a content delivery network: servers near the shopper that keep copies of pages), so their HTML is the same for everyone and can't contain this shopper's cart count.
Without the snapshot, the badge pops in half a second late. With it, the header paints "3" on the first frame, and GET /api/cart confirms it in the background. Reserve the badge's width in CSS either way, so a late number never shifts the layout.
type Snapshot = { count: number; version: number; owner: string };
export function readSnapshot(owner: string): Snapshot | null {
try {
const raw = localStorage.getItem("cart:snapshot");
const snap = raw ? (JSON.parse(raw) as Snapshot) : null;
// A snapshot from another person on a shared computer is worse than none.
return snap && snap.owner === owner ? snap : null;
} catch {
return null; // storage blocked or corrupt: just wait for the server
}
}
export function writeSnapshot(cart: { version: number; count: number }, owner: string) {
const current = readSnapshot(owner);
if (current && current.version >= cart.version) return; // never go backwards
try {
localStorage.setItem("cart:snapshot", JSON.stringify({ ...cart, owner }));
} catch {}
}
Check your understanding · Exercise 3
With a server cart for everyone and a localStorage snapshot, which statements are true (select all that apply)? Select all that apply.
Merging a guest cart at sign-in
On Wednesday Priya signs in on her laptop, where her account cart holds a desk lamp from last month. The shoes and socks are still in her phone's guest cart. When she signs in on the phone too, that guest cart meets the account cart: two carts now belong to one person, and the design must say exactly what the result is.
Where the merge runs matters as much as the rule. Run it on the server, inside the sign-in request, in one database transaction: read the guest cart named by the cookie, read the account cart, write the merged lines, mark the guest cart as merged, and answer with the new session cookie and an expired cart_id cookie.
If the merge ran in the browser after sign-in, a closed tab or a dropped request halfway through would leave two half-merged carts, and the next device to load would see a mess.
mergedInto make a retried or parallel sign-in harmless: the second merge finds the marker and changes nothing.Decision When the same product is in both carts, what quantity should the merged cart hold?
What we pick: Keep every line from both carts, and for a product in both, keep the larger quantity. It never drops anything the shopper chose, it doesn't double the common case of the same item added on two devices, and it's naturally idempotent. A grocery store might choose "add them up", which is fine as long as the merge is guarded by a merged marker so it can't run twice. Whatever the rule, cap the result and tell the shopper what happened.
Merged with keep larger: subtotal 191 dollars. Nothing lost, nothing doubled. The sold-out lamp stays visible but doesn't count in the subtotal.
The edge cases that decide whether the merge is trusted
- Same product, different variant. Size 42 and size 43 are different SKUs, so they are different lines. Merge by SKU, never by product name.
- Personalized lines. A mug engraved "Anna" and the same mug engraved "Ben" share a SKU but are different things. Lines with custom options are never merged; both are kept.
- Limits. Cap each merged line at stock and at the product's
maxPerOrder. If the two carts together pass the 100-line limit, keep the most recently changed lines and move the rest to "Saved for later" instead of deleting them. - Sold-out lines are kept and marked, so the shopper sees them and decides. They don't count in the subtotal.
- Promo codes. If both carts carry a code and the store allows only one, the server re-checks both against the merged cart and keeps the one that is valid and saves more, then says which one it kept.
- Empty carts. If the account cart is empty, don't copy lines: just attach the guest cart to the user. If the guest cart is empty, there is nothing to do and nothing to announce.
- Retries and races. A retried sign-in, or two tabs signing in at once, must not merge twice. Lock the guest cart row in the transaction and set
mergedIntoon it; a merge that finds the marker returns the account cart unchanged. - Sign-in in the middle of checkout. The account cart may hold extra lines, like the lamp. Never carry them into a payment silently: send the shopper back to the review step with a notice, because the total they were about to approve has changed.
// Server side, inside the sign-in transaction.
function mergeCarts(guest: Cart, account: Cart, catalog: Catalog): MergeResult {
if (guest.mergedInto) return { cart: account, notices: [] }; // already done
const lines = new Map(account.lines.map((l) => [lineKey(l), l]));
const notices: Notice[] = [];
for (const g of guest.lines) {
const key = lineKey(g); // sku, plus custom options if any
const a = lines.get(key);
const wanted = a ? Math.max(a.quantity, g.quantity) : g.quantity;
const { stock, maxPerOrder } = catalog.get(g.sku);
const quantity = Math.min(wanted, maxPerOrder, Math.max(stock, 1));
if (quantity < wanted) notices.push({ kind: "capped", sku: g.sku, wanted, quantity });
lines.set(key, { ...(a ?? g), quantity, updatedAt: latest(a, g) });
}
guest.mergedInto = account.id;
return { cart: { ...account, lines: [...lines.values()], version: account.version + 1 }, notices };
}
The client's part is small but visible. The sign-in response carries the merged cart and its notices; show them once, in plain words: "We added 2 items from your last visit", "Trail shoes: only 1 left, so your cart has 1". Then broadcast an auth change to other tabs (chapter 6) so none keeps showing the guest cart.
Check your understanding · Exercise 4
Priya is on the payment step as a guest with $143 of items (shoes at $119, two pairs of socks at $12). She signs in to use a saved card, and her account cart holds a $45 lamp. What should happen (select all that apply)? Select all that apply.
Quantity changes that race each other
Priya wants three pairs of socks, then changes her mind to two. She taps + twice and - once in under a second. The naive stepper waits for the server after each tap: it shows a spinner, sends PUT, and updates the number when the reply comes. On a phone with a 300 ms round trip, that's almost a second of a stepper that ignores her, and many shoppers tap again because nothing seemed to happen.
The next naive fix updates the number at once and sends one "+1" or "-1" request per tap. Now three requests race, and replies don't have to arrive in order: one can hit a slower server, one can be retried after a timeout.
If the reply to the second tap (quantity 3) lands last, the stepper jumps back to 3 although she wanted 2. Worse, if the client retries a "+1" that the server had already applied before the connection dropped, she gets a pair she never asked for.
Decision How should the quantity stepper talk to the server?
What we pick: Optimistic, absolute, one request in flight per line. Sending the absolute value makes every request safe to repeat, the version check throws away stale replies, and coalescing taps cuts requests. The extra code is small and shared by the stepper, the remove button, and "Save for later".
Priya taps + three times and - once. She wants 3.
- 0, 100, 200 ms: three +1 requests; the stepper shows 2, 3, 4
- 300 ms: reply 2; 400 ms: reply 3
- 550 ms: - from the 3 on screen shows 2 and sends -1
- 850 ms: reply 3
- 1100 ms: the slow reply for the third tap says 4
Screen 4, server 3. Wrong: the slow reply lands last, so the screen ends on 4 while the server holds 3. The cart page and checkout will contradict it.
What the page shows: confirmed plus pending
The cart store keeps two things apart: the confirmed cart, which is the last full cart the server returned, with its version, and a map of pending changes, one per line, holding the quantity the shopper wants. The page always renders the confirmed cart with the pending quantities laid on top. A reply never overwrites what the shopper is still changing, because the pending value sits above it until the request that carries it is answered.
function setQuantity(lineId: string, wanted: number) {
const change = pending.get(lineId) ?? { wanted, inFlight: false, timer: 0 };
change.wanted = wanted;
pending.set(lineId, change);
render(); // the stepper shows the new number this frame
clearTimeout(change.timer);
change.timer = window.setTimeout(() => flush(lineId), 250);
}
async function flush(lineId: string) {
const change = pending.get(lineId);
if (!change || change.inFlight) return; // the reply handler calls us again
const sent = change.wanted;
change.inFlight = true;
try {
acceptCart(await api.put(`/api/cart/items/${lineId}`, { quantity: sent }));
if (change.wanted === sent) pending.delete(lineId);
} catch {
pending.delete(lineId); // back to the confirmed quantity
announce("Couldn't update the quantity. Please try again.");
} finally {
change.inFlight = false;
if (pending.get(lineId) === change) flush(lineId); // she kept tapping
render();
}
}
- Roll back to the confirmed value, not the previous local one. After three taps, "the value before the last tap" is a guess the server never saw. The confirmed cart is the only number both sides agree on. A timeout can first be retried with the latest wanted value, since absolute quantities are safe to resend; roll back only when the retries give up.
- Be optimistic about quantity, not money. Grey the line total and the subtotal while a line is pending, and fill them in from the reply. A "3 for 2" offer or a tax rule can make the real total different from quantity times price.
- Let the server correct you. If she asks for 5 and only 3 are left, the server applies 3 and returns a notice. The stepper settles on 3 and a message under the line explains why.
- Don't disable the stepper while a request is in flight. Disabled buttons drop focus and ignore keyboard users. Keep them enabled and let the queue absorb the taps; disable + only at the line's limit.
- Removing a line is a quantity of 0, with an "Undo" link for a few seconds that sets it back. Undo is friendlier than a confirmation dialog, and it uses the same request.
Add to cart, exactly once
Adding a new product is different: the line doesn't exist yet, so there's no line id to set an absolute quantity on. The client creates an idempotency key (a random id, from crypto.randomUUID()) for each press of the button and sends it in an Idempotency-Key header with POST /api/cart/items.
If the request times out and the client retries with the same key, the server recognizes the key and returns the cart it already produced instead of adding a second unit. A new press gets a new key, so a shopper who really wants two can press twice.
On screen, the press bumps the header count and opens the drawer with what the page already knows (name, image, size, price), marked as adding. The confirmed line replaces it when the reply arrives, usually a few hundred milliseconds later. If the server refuses (sold out a second ago), the drawer says so and the count goes back.
Check your understanding · Exercise 5
Socks are confirmed at quantity 1 (cart version 7). Priya taps + three times, so the stepper shows 4 and one request with quantity 4 is sent. Before it returns, she taps - once. Then the request fails. What should the stepper show (select all that apply)? Pick one.
Keeping tabs and devices in step
Shoppers open many tabs: one product per tab, then a cart tab to compare. Every tab in the same browser sends the same cookie, so they all read the same server cart. The problem is that they don't know when it changed.
Priya removes the socks in tab A; tab B still shows "3" in the header and an old cart in its drawer until something makes it ask again. Across devices it's the same problem with a longer gap: the laptop doesn't know what the phone did an hour ago.
A stale tab is not a money bug, because the server prices every request and checks everything again at checkout. It's a trust bug: the drawer says three items, the cart page says two. So the goal is modest: tabs in one browser agree within a fraction of a second, and another device is right whenever the shopper looks at it.
Decision How should a tab tell the other tabs in the same browser that the cart changed?
What we pick: Use BroadcastChannel for messages between tabs, and keep the refetch on becoming visible as the safety net for missed messages and for other devices. The storage event is a fine fallback for very old browsers, but in 2026 the channel is available everywhere we care about, and it keeps cart messages and auth messages out of storage.
BroadcastChannel at once. Another device asks the server when it comes back into view, with the version as If-None-Match, so an unchanged cart costs a tiny 304.const channel = new BroadcastChannel("cart");
// Called whenever this tab gets a cart from the server.
function acceptCart(cart: Cart, fromOtherTab = false) {
if (store.confirmed && cart.version <= store.confirmed.version) return;
store.confirmed = cart;
writeSnapshot({ version: cart.version, count: countItems(cart) }, owner());
if (!fromOtherTab) channel.postMessage({ type: "cart", cart });
render();
}
channel.onmessage = ({ data }) => {
if (data.type === "cart") acceptCart(data.cart, true);
if (data.type === "auth") location.reload(); // someone signed in or out
};
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") refetchCart(); // sends If-None-Match
});
Other devices: ask at the right moments
Pushing cart changes to every device over a WebSocket would work, but a cart changes a few times per session, and a store with 2 million visits a day would hold tens of thousands of open connections to deliver almost nothing. Instead, the client asks when the shopper is about to look: a tab becomes visible or focused, the drawer or cart page opens, checkout starts. Each ask sends the cart version as If-None-Match; most answers are a bodiless 304.
| Moment | What the client does | Typical cost |
|---|---|---|
| Another tab confirms a change | Applies the cart from the channel message | No request |
| Tab becomes visible or focused | GET /api/cart with If-None-Match | About 200 bytes when unchanged |
| Drawer or cart page opens | Same conditional request, then shows the answer | One round trip, usually a 304 |
| Checkout starts | Server re-prices and pins the cart version | One request that the shopper waits for |
Two devices can still change the same line at the same time. With absolute quantities, the last write wins, and that's acceptable: it's one person, and they see the result the next time they look.
What must not happen is a change sneaking into a checkout already in progress on the other device. Checkout pins the cart version it reviewed; if the cart changes before the order is placed, placing the order fails with a conflict and the shopper goes back to review (chapter 11).
Check your understanding · Exercise 6
Which of these situations does tab and device sync, on its own, handle correctly (select all that apply)? Select all that apply.
Prices, stock, and promotions: the client shows, the server decides
On Monday the shoes cost $140. On Thursday a sale drops them to $119, and the last desk lamp sells to someone else. On Friday Priya opens her cart.
The naive cart either shows Monday's prices (because it saved them with the line) or adds up prices that the client loaded with the product page. Both are wrong in a way that matters: either she sees a total that checkout will contradict, or the client is computing a number it has no right to compute.
There's also a security side. If the client sends { sku, quantity, price: 119 } and the server trusts the price, anyone with the browser's developer tools can buy the shoes for $1. The rule is simple and absolute: requests carry intent ("set line ln_8f2 to 2", "apply code SPRING10"), never money. Every number the client shows about money is either a copy of a server answer or a clearly labelled estimate.
Decision When and where should prices, stock, and discounts be checked?
What we pick: Re-price on every read and pin a quote at checkout. The cart page is always fresh, and the one moment where freshness costs real money, the payment, is protected by a quote that must match. Reserving stock only at checkout keeps bots and abandoned carts from locking up the best sellers.
Every surprise becomes a status with a message
The server compares each line with today's catalog and sets a status. The client never hides a change; it turns each status into one plain sentence next to the line and, where it helps, one action. The shopper should never need to work out what changed by comparing numbers.
| Status | What happened | What the line says | Counts in subtotal |
|---|---|---|---|
price_down | Today's price is lower than priceAtAdd | "Price dropped from $140 to $119" | Yes, at $119 |
price_up | Today's price is higher | "Price went up from $40 to $45 since you added it" | Yes, at $45 |
low_stock | Fewer than 5 left | "Only 3 left" | Yes |
reduced | Wanted more than are left | "Only 2 left, so we changed your quantity to 2" | Yes, at the new quantity |
limit | Wanted more than maxPerOrder | "Limit 2 per order" | Yes, capped |
out_of_stock | None left | "Sold out" with "Save for later" and "Remove" | No |
unavailable | Can't ship to this region or discontinued | "Not available for delivery to Germany" | No |
- Don't leak exact stock. Return
availablecapped at 20, so competitors can't read inventory from the cart API. The stepper's + button is disabled at the lower ofavailableandmaxPerOrder, and a hint says why. - Keep sold-out lines. Removing them silently makes the shopper think the site lost them. Keep them visible, out of the subtotal, with "Save for later" as the main action.
- Promo codes are server answers too.
POST /api/cart/promo { code }returns the cart with the discount applied or a reason it wasn't: expired, not valid for these items, or a minimum not met. Show the reason under the field, not a generic "invalid code". - Discounts can stop applying. Removing a line may drop the cart below a code's $50 minimum. The server removes the discount and returns a notice: "SPRING10 needs $50, your cart is $42." The client shows it where the discount line used to be.
- Label estimates. Before an address is known, tax and shipping are estimates ("Shipping: calculated at checkout" or "Estimated tax: $9.52"). Say so, so the final total isn't a surprise.
One subtlety: product pages are cached at the CDN for about 60 seconds, so for a minute after a price change the product page and the cart can disagree. That's fine: the cart is never cached (Cache-Control: private, no-store), and the cart's price is the one that counts.
Check your understanding · Exercise 7
Which requests from the client are safe for the server to act on (select all that apply)? Select all that apply.
Cart drawer, cart page, or both
After "Add to cart", the shopper needs proof it worked and a way to keep going. The naive choices sit at the extremes: navigate to the cart page after every add (proof, but it throws the shopper out of browsing), or only bump the header count (no interruption, but easy to miss, and nobody can tell which size was added).
Decision What should the shopper see right after adding, and where do they edit the cart?
What we pick: Both. The drawer gives proof and a quick edit in place; the page is the full, linkable view. They read the same cart store and use the same line component, so keeping them consistent is free. On small phones the drawer can cover the full screen, which is fine because it closes with one tap or the Escape key.
dialog: focus moves in, the page behind is inert, and Escape closes it. The page is the full view with a real URL. Both read the same cart store.The drawer is a modal dialog, so build it as one: the native dialog element opened with showModal() gives the right role, moves focus in, makes the page behind it inert, and closes on Escape.
Put focus on the drawer's heading ("Added to your cart", made focusable with tabindex="-1") so a screen reader announces it, and make sure focus returns to the "Add to cart" button on close. Current browsers do this for a modal dialog; a custom drawer must do it by hand.
Keep the drawer's code in its own chunk, loaded when the shopper first hovers or focuses "Add to cart", so product pages don't pay for it up front.
Check your understanding · Exercise 8
Which choices make the cart drawer work well (select all that apply)? Select all that apply.
Checkout as a state machine with saved progress
Checkout collects four things in order: where to ship, how fast, how to pay, and a final yes. The naive build is a few step components with flags (step, isLoading, hasAddress, isPaying) in React state.
Then a shopper reloads on the payment step and lands on an empty address form. Another presses back and leaves checkout entirely. A third goes back, changes the country to Canada, and still sees a US shipping price, because nothing told the shipping step its input changed. Flags also allow impossible combinations, like paying while the address is invalid.
Decision How should the checkout flow be structured, and where does its progress live?
What we pick: Steps with URLs, a state machine, and a server session. The redirect to a bank or PayPal alone rules out in-memory state, and the machine turns the rules ("no shipping without an address") into data instead of scattered ifs. Returning shoppers with a saved address and card skip straight to review, so the steps cost them nothing.
resumeStep sends the shopper to it.A state machine is a list of states, the events each state accepts, and where each event goes. Anything not in the list is ignored, which is how it rules out impossible states. Ours has a state per step, plus the states of placing the order. Guards are conditions on a transition: the step for shipping is reachable only if the session has a valid address.
type Step = "address" | "shipping" | "payment" | "review";
type State = Step | "placing" | "needs_action" | "confirmed";
const machine: Record<State, Partial<Record<string, State>>> = {
address: { ADDRESS_SAVED: "shipping" },
shipping: { METHOD_SAVED: "payment", EDIT_ADDRESS: "address" },
payment: { PAYMENT_READY: "review", EDIT_SHIPPING: "shipping" },
review: { PLACE: "placing", EDIT_PAYMENT: "payment", EDIT_ADDRESS: "address" },
placing: { PAID: "confirmed", ACTION_REQUIRED: "needs_action", DECLINED: "payment", CART_CHANGED: "review" },
needs_action: { PAID: "confirmed", DECLINED: "payment" },
confirmed: {},
};
export function next(state: State, event: string): State {
return machine[state][event] ?? state; // unknown events change nothing
}
// Where a reload lands: the first step the session can't skip.
export function resumeStep(s: CheckoutSession): Step {
if (!s.address?.valid) return "address";
if (!s.shippingMethod) return "shipping";
if (!s.paymentMethod) return "payment";
return "review";
}
Saved progress, and what invalidates it
- Save each step to the server when it completes:
PATCH /api/checkout/:id { address }. The answer is the whole session, re-priced: a new address can change tax, shipping options, and which items can be delivered. - Half-typed forms go to
sessionStorage, keyed by checkout id, so a reload keeps them. Never the card number: card fields belong to the payment provider and are never in our storage at all. - Changing an earlier step clears what depends on it. A new country clears the shipping method if it isn't offered there, and the shopper lands on shipping again with a note. The machine stays in its states; the server decides which saved choices are still valid.
- Sessions expire. After the 15-minute quote runs out, the next save gets a fresh quote. If anything changed, the review step shows what and why before the shopper can pay.
- A reload on any URL resumes correctly. Opening
/checkout/ch_9/reviewwith no payment method redirects to/checkout/ch_9/payment, usingresumeStep.
Check your understanding · Exercise 9
Priya is on review. She goes back to address and changes the country from the US to Canada. What should happen (select all that apply)? Select all that apply.
Payment: hosted fields, redirects, 3-D Secure, and never charging twice
The naive payment step is our own form: card number, expiry, and CVC inputs, posted to our server, which calls the payment provider. It puts every card number through our JavaScript, our servers, and our logs, which pulls the whole site into the strictest part of PCI DSS, the card industry's security standard: hundreds of requirements, a yearly assessment, and a breach that leaks cards instead of cart contents.
The second naive mistake is the button. A shopper on a slow train taps "Place order", sees nothing for two seconds, and taps again. Or the request reaches the server, the card is charged, and the reply is lost in a tunnel; the page shows an error, and the shopper tries again. Both stories end with two charges unless the design makes the second attempt harmless.
Decision How should card details be collected?
What we pick: Hosted fields for cards, the provider's buttons for Apple Pay and Google Pay, and a redirect only for methods that need one, like PayPal. That keeps card data out of our code and our audits while the shopper stays on our page for the common case. The checkout state lives on the server, so the redirect case resumes cleanly when the shopper returns.
3-D Secure: when the bank wants to check
3-D Secure (3DS; today version 2, also called EMV 3DS) lets the card's bank confirm the shopper is the card holder. In the European Economic Area and the UK, strong customer authentication rules require it for most online card payments, with exemptions such as small amounts.
Most of the time the check is invisible ("frictionless"): the bank decides from device and purchase data the store sends. Sometimes the bank shows a challenge: a code by text message or an approval in its app, displayed in a frame on our page or as a redirect.
So a payment has three outcomes, not two: succeeded, declined, or needs action. In our machine, needs action is a real state: the client shows the challenge, waits, and asks the server for the final result; it never assumes success because the challenge window closed.
Idempotency keys: why a double click never charges twice
An operation is idempotent when doing it twice has the same effect as doing it once. Placing an order isn't naturally idempotent, so we make it so with three layers. First, the button: the machine leaves review for placing on the first click, and placing has no PLACE event, so the second click does nothing.
Second, the request: the client sends POST /api/checkout/:id/place with an Idempotency-Key header. The server stores the key with the result; a repeated key gets the stored result back instead of a new charge. Payment providers work the same way: Stripe, for example, remembers a key for at least 24 hours and rejects a reused key sent with different parameters.
Third, the data: one checkout session can produce at most one paid order, enforced by a unique constraint in the database, so even a bug in the first two layers can't create a second order.
// One key per attempt to pay, kept across reloads of this tab.
function attemptKey(checkoutId: string): string {
const name = `pay-attempt:${checkoutId}`;
let key = sessionStorage.getItem(name);
if (!key) {
key = crypto.randomUUID();
sessionStorage.setItem(name, key);
}
return key;
}
export async function placeOrder(checkoutId: string, expectedTotal: number) {
return fetch(`/api/checkout/${checkoutId}/place`, {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": attemptKey(checkoutId) },
body: JSON.stringify({ expectedTotal }),
});
}
// After a decline, the shopper may change the card: that's a new attempt.
export function newAttempt(checkoutId: string) {
sessionStorage.removeItem(`pay-attempt:${checkoutId}`);
}
Check your understanding · Exercise 10
Which statements about the payment step are true (select all that apply)? Select all that apply.
Placing the order, confirming it, and recovering from errors
"Place order" is the one request in the whole design where the client must not guess. The naive client treats it like any other call: success shows a thank-you message, anything else shows "Something went wrong, please try again". That message is a trap. If the request timed out after the card was charged, "try again" invites a second payment, and the shopper who is careful enough not to retry is left not knowing whether they bought anything.
So the client treats the answer as one of five outcomes, each with its own next state in the machine. The important one is the fifth: when the client doesn't know, it says so honestly and finds out, instead of picking an answer.
| Answer | Meaning | What the client does |
|---|---|---|
200 with orderId | Paid, order created | Replace the URL with the confirmation page, clear the attempt key, broadcast the empty cart |
202 with requires_action | The bank wants 3-D Secure | Show the challenge, then ask the server for the final status |
402 with a decline code | The card was declined | Back to payment with a plain reason; keep everything else; a new attempt key |
409 with a new quote | Cart, prices, or stock changed, or the quote expired | Back to review with the changes highlighted; the shopper confirms again |
| Timeout or network error | Unknown: the charge may or may not have happened | Show "Checking your payment", retry with the same key or poll the status |
async function submitOrder(id: string, expectedTotal: number) {
send("PLACE");
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await placeOrder(id, expectedTotal); // same key every time
const body = await res.json();
if (res.status === 200) return finish(id, body.orderId);
if (res.status === 202) return send("ACTION_REQUIRED", body);
if (res.status === 402) return send("DECLINED", body.reason);
if (res.status === 409) return send("CART_CHANGED", body.quote);
throw new Error(`unexpected ${res.status}`);
} catch {
showChecking(); // "Checking your payment. Please don't pay again."
await wait(1000 * 2 ** attempt); // 1 s, 2 s, 4 s, 8 s
}
}
showUnknown(id); // "We'll email you within 10 minutes", with a link to order status
}
function finish(checkoutId: string, orderId: string) {
newAttempt(checkoutId);
router.replace(`/orders/${orderId}/confirmation`); // back button skips checkout
}
A confirmation page that survives a reload
The confirmation is a real page at /orders/:orderId/confirmation, rendered from the order on the server, not from data the checkout happened to hold in memory. Reloading it, bookmarking it, or opening it from the email shows the same order. The client reaches it with a URL replace, not a push, so the back button returns to the store instead of the review step.
The server empties the cart when it creates the order; the client posts the new, empty cart on the BroadcastChannel so other tabs drop their badge to zero. Guests see the page through a short-lived signed token in a cookie, so an order number alone never reveals an address.
Redirect methods return the same way. PayPal sends the shopper back to /checkout/:id/return with some query parameters. The client never trusts them: it asks our server, which asks the provider, and only then moves to confirmation or back to payment. Card payments with a 3-D Secure redirect use the same return route.
Errors and recovery at every step
| Step | What goes wrong | Recovery |
|---|---|---|
| Cart | Add fails, item sold out a second ago | Line-level message in the drawer, count restored, "Notify me" offered |
| Address | Invalid postal code, address not found | Field errors on blur and on submit; an address suggestion the shopper can accept or ignore |
| Shipping | No method ships to this address | Say which items can't ship there; offer to remove them or change the address |
| Payment | Decline, wrong CVC, provider script failed to load | Plain reason from the decline code; keep the form; offer other methods if the script fails |
| Review | Prices or stock changed, quote expired | Highlight each change; require a new confirmation |
| Placing | Timeout, unknown outcome | Retry with the same key, then poll the status; never say "failed" without knowing |
- Never lose what the shopper typed. A failed step keeps every field; only the card fields may need to be entered again, and the message says so.
- Say what happened and what to do next, in the shopper's terms: "Your bank declined this card. Try another card or contact your bank." Map provider decline codes to a few plain messages; never show the raw code.
- Move focus to the error. On a failed submit, focus a summary at the top of the step that links to each field with a problem (chapter 13 has the details).
Check your understanding · Exercise 11
The "Place order" request times out after 15 seconds. What should the client do (select all that apply)? Select all that apply.
Architecture, data model, and API
The pieces fall out of the decisions. On the client, one cart store holds cart data in memory; the badge, the drawer, and the cart page are views of it. A mutation queue owns all cart writes, tab sync owns the channel and the snapshot, and the checkout machine owns which step is allowed. A small payment adapter wraps the provider's script, so adding PayPal touches one module.
On the server, a backend-for-frontend (BFF: an API layer built for this one frontend) answers in cart and checkout shapes and talks to pricing, inventory, tax, and payments behind it.
| Part | Responsibility | Why the boundary is here |
|---|---|---|
| Cart views | Header badge, drawer, cart page: render and dispatch intents | Three views, one truth: none of them fetch or store the cart |
| Cart store | Confirmed cart and version, pending changes, derived view | One place to apply the version rule and the optimistic overlay |
| Mutation queue | Coalesce taps, one request in flight per line, roll back on failure | Race handling lives in one tested module, not in every button |
| Tab sync | BroadcastChannel messages, snapshot, refetch on visibility | Sync policy can change without touching views |
| Checkout machine | States, events, guards, resume step | Flow rules as data; step pages stay simple forms |
| Payment adapter | Loads the provider script, mounts hosted fields, runs 3-D Secure | Card data and provider quirks stay in one place, loaded only on the payment step |
| BFF | Cart, merge, checkout session, quote, place order | Pricing and money rules on the server, in the shape the client needs |
The data model follows the same rule: the server owns everything about money and the client owns only what it's waiting on. Money is always an integer amount in the currency's minor unit, plus an ISO 4217 currency code: $119.00 is 11900 cents, 1,500 yen is 1500 because yen have no minor unit, and a few currencies such as the Kuwaiti dinar use three decimals.
Integers matter because 0.1 + 0.2 is not 0.3 in floating point, and a cart that's off by a cent fails reconciliation with the payment provider.
type Money = { amount: number; currency: string }; // 11900 + "USD" is $119.00
type CartLine = {
lineId: string; sku: string; quantity: number;
title: string; image: string; options: Record<string, string>;
unitPrice: Money; priceAtAdd: Money; lineTotal: Money;
available: number; maxPerOrder: number; status: LineStatus;
};
type Cart = {
id: string; version: number; currency: string;
lines: CartLine[]; saved: CartLine[];
discounts: { code?: string; label: string; amount: Money }[];
subtotal: Money; estimatedTax?: Money; notices: Notice[];
};
type CheckoutSession = {
id: string; cartVersion: number; quoteExpiresAt: string;
address?: Address; shippingMethod?: string; paymentMethod?: string;
totals: { items: Money; shipping: Money; tax: Money; total: Money };
state: "open" | "placing" | "needs_action" | "completed";
};
// Client only, in memory.
type Pending = Map<string, { wanted: number; inFlight: boolean; timer: number }>;
GET /api/cart If-None-Match: "v12" -> 304 or Cart
POST /api/cart/items Idempotency-Key { sku, quantity } -> Cart
PUT /api/cart/items/:lineId { quantity } (0 removes) -> Cart
POST /api/cart/items/:lineId/move { to: "saved" | "cart" } -> Cart
POST /api/cart/promo { code } -> Cart, or 422 { reason }
POST /api/checkout -> CheckoutSession (quote, stock held)
PATCH /api/checkout/:id { address } | { shippingMethod } -> CheckoutSession
POST /api/checkout/:id/place Idempotency-Key { expectedTotal }
-> 200 { orderId } | 202 { action } | 402 | 409
GET /api/checkout/:id/status -> { state, orderId? }
Error body, every endpoint:
{ "error": { "code": "quote_expired", "message": "Prices were updated.", "retryable": false } }
Check your understanding · Exercise 12
Why does every cart write return the whole cart instead of just the changed line (select all that apply)? Select all that apply.
Performance, accessibility, internationalization, and funnel analytics
A cart and checkout is where speed, clarity, and trust turn directly into revenue. This chapter goes one level deeper than a checklist in the four areas interviewers ask about most.
Fast add to cart and a good INP
INP measures the time from a tap to the next frame painted, and "Add to cart" is the tap that matters most on a product page. Keep its handler tiny: update the store, bump the badge, open the drawer, and return.
Everything else (the network request, the analytics event, preloading the cart page) runs after the frame, by yielding to the browser first with scheduler.yield() where it exists and a setTimeout fallback. Load the drawer's code when the shopper hovers or focuses the button, so the first open doesn't wait for a download. On a mid-range phone this keeps the interaction well under the 200 ms "good" threshold, even though the server takes 150 to 300 ms to confirm.
- Checkout pages render on the server so the form appears without waiting for JavaScript; they are personal, so
Cache-Control: private, no-store. - Load the payment provider's script only on the payment step, and add a
preconnectto its origin on the shipping step, so the hosted fields appear quickly when needed without slowing the first steps. - Keep third-party tags off checkout. Each one costs main-thread time and adds to the script risk from chapter 10.
Accessibility: announcements and forms
Most cart changes happen away from where the eye or the screen reader cursor is. Use one polite live region (role="status") for cart news: "Trail shoes, size 42, added. Your cart has 3 items." For the stepper, announce the settled result once the server answers ("Quantity of wool socks updated to 2, subtotal $143"), not every tap.
Give each stepper button a name that includes the product ("Increase quantity of wool socks"), and when + is disabled at the limit, link the reason with aria-describedby. Make targets at least 24 by 24 CSS pixels (WCAG 2.2), and 44 in practice on phones.
- Every field has a visible label and the right
autocompletetoken (shipping street-address,shipping postal-code,email,tel), so browsers and password managers fill the form in one tap. - On a failed submit, move focus to an error summary at the top of the step that links to each field; mark fields with
aria-invalidand connect messages witharia-describedby. Don't validate on every keystroke. - Don't ask twice. A "Billing address same as shipping" checkbox, checked by default, meets WCAG 2.2's rule against redundant entry and saves a whole form.
- Respect reduced motion for the drawer's slide-in, and never block paste in any field.
Internationalization: currencies, taxes, and addresses
Each store region has one currency, and the server prices the cart in it; the client never converts. Format with Intl.NumberFormat(locale, { style: "currency", currency }) after dividing the integer amount by 10 to the power of the currency's decimals, which resolvedOptions().maximumFractionDigits reports (2 for USD, 0 for JPY, 3 for KWD). The formatter knows the symbol, the separators, and the order: German puts the symbol after the number ("119,00 €").
Tax display differs too: EU and UK consumer prices must include VAT, so the subtotal already contains tax, while US prices add sales tax once the delivery address is known, and the cart must say which ("incl. VAT" or "Tax calculated at checkout").
Addresses vary the most: field order, which fields exist, and whether a postal code is required all depend on the country. Ask for the country first, then render that country's form from data (Google's open-source address metadata is a common source), use one "Full name" field, and lay out the whole flow with logical CSS properties so right-to-left languages work.
Funnel analytics
Track one event per funnel step, with the cart or checkout id and the value: view_cart, add_to_cart, begin_checkout, add_shipping_info, add_payment_info, purchase (these are the GA4 ecommerce names, which most tools understand). Send client events with navigator.sendBeacon so leaving the page doesn't drop them, and send purchase from the server when the order is created, because ad blockers and closed tabs lose client events exactly at the most important step.
Add the reasons, not just the counts: validation errors per field, decline codes, 409 conflicts, time spent per step. A drop at the shipping step with many postal code errors points at the form, not the prices.
Check your understanding · Exercise 13
Which of these improve the cart and checkout for real shoppers (select all that apply)? Select all that apply.
Running it in the interview
Forty-five minutes is not enough to say everything in this guide, so the skill is choosing. Lead with the two scary requirements (the cart follows the shopper, the payment never happens twice), because they justify the server cart and the idempotent payment, and every later choice hangs off them. Draw the architecture early and simply: views, cart store, mutation queue, checkout machine, payment adapter, BFF. Then go deep where the interviewer leans.
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask the scope questions; write the numbers | "Guests and accounts, many devices, server-decided prices, cards with 3-D Secure" |
| 5 to 12 | Where the cart lives; draw the architecture | "Server cart for everyone, guest id in an HttpOnly cookie, a snapshot only for the badge" |
| 12 to 20 | Merge at sign-in; optimistic quantities | "Keep the larger, capped, idempotent. Absolute quantities, one request in flight, a version" |
| 20 to 30 | Checkout machine and payment | "Steps with URLs and a server session. Hosted fields. Three layers against double charges" |
| 30 to 38 | Revalidation, errors, the unknown outcome | "The client shows, the server decides; a quote pinned at checkout; never say failed when unsure" |
| 38 to 45 | Performance, accessibility, i18n, analytics; follow-ups | Pick two areas and go one level deep in each |
Signals that separate levels: a junior answer has a cart page and a checkout form. A mid-level answer has a server cart, optimistic updates, and hosted card fields. A senior answer names the failure each choice prevents, with a number or a story: the delta that doubles on retry, the merge that runs twice, the timeout after the charge. Say trade-offs without being asked, and say what you'd measure.
Think first
Follow-up: "A flash sale has 500 units and 50,000 shoppers. What changes?"
Stock becomes the hard part. Holding stock at add-to-cart would let carts and bots lock all 500 units in seconds, so keep reserving at checkout start, with a shorter hold (5 minutes instead of 15) and a clear countdown. Expect many 409s at placing: make the message specific ("Sold out while you were checking out") and return the shopper to a cart that still holds everything else.
On the client, protect the servers from the clients: jittered retries, respecting Retry-After, and a waiting-room page served from the CDN if the store uses one. Prices and the product page can be cached harder; the cart and checkout never are.
Think first
Follow-up: "What if the shopper is offline when they change a quantity?"
Keep the pending change and mark the line "Not saved yet". Send it when the online event fires or the next refetch succeeds; because the request carries an absolute quantity, sending it late is still safe. Don't let checkout start while changes are unsent, since checkout needs a fresh quote anyway.
A full offline cart with a sync engine is rarely worth it for a store; say why (prices and stock need the server) rather than building it by reflex.
Summary
- Keep the cart on the server for guests and accounts alike, with a guest id in an
HttpOnlycookie; the browser holds only a copy and a count snapshot for the first paint. - Merge at sign-in on the server, in one transaction: keep every line, keep the larger quantity for duplicates, cap by stock and limits, mark the guest cart merged, and tell the shopper what changed.
- Make quantity changes optimistic but send absolute quantities, keep one request in flight per line, drop replies with an older version, and roll back to the last confirmed cart.
- Sync tabs with
BroadcastChanneland refetch with an ETag when a tab becomes visible; that also covers other devices without push. - The client shows money, the server decides it: re-price on every read, turn every change into a plain message, and pin a quote at checkout that the order must match.
- Build checkout as a state machine with a URL per step and a server session, so reloads, back buttons, and bank redirects all resume correctly.
- Take cards with hosted fields to stay in the smallest PCI scope, treat 3-D Secure as a real state, and stop double charges with three layers: the machine, an idempotency key, and one paid order per checkout.
- When the outcome of placing an order is unknown, say so, retry with the same key, and poll; never tell the shopper it failed without knowing.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- BroadcastChannel (MDN) The API for messages between tabs of the same origin.
- Interaction to Next Paint (web.dev) How INP is measured and the 200 ms threshold.
- Stripe API: idempotent requests How a payment provider uses idempotency keys, and for how long it remembers them.
- PCI Security Standards Council: document library PCI DSS and the self-assessment questionnaires (SAQ A, A-EP, D).
- Baymard Institute: cart abandonment rate statistics The abandonment rate and the reasons shoppers give.
- WCAG 2.2: Redundant Entry Why "same as shipping" matters.
- Intl.NumberFormat (MDN) Formatting money for a locale and currency.
- The dialog element (MDN) A native modal for the cart drawer.
- HTML autocomplete attribute (MDN) The tokens that let browsers fill address forms.
Concepts behind it
- Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Request races and cancellationResponses can come back in any order, so a page can show the answer to an old question; learn to let only the latest answer reach the screen and to cancel the work nobody needs.
- Retries, timeouts, and backoffHow a client survives a flaky network and a struggling server: give every request a deadline, retry only the failures that can heal, space the retries out with exponential backoff and jitter, and stop retrying when the server or the network is clearly down.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Syncing state across tabsEvery open tab of your app is a separate program with its own memory, so a change in one tab is invisible to the others until you send it across with the storage event, BroadcastChannel, a SharedWorker, or a leader tab chosen with Web Locks.
- Forms at scaleHow to hold, validate, and submit form data so users see the right error at the right moment, never lose what they typed, and get the same rules on the client and the server.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Dates, numbers, and pluralsTurning raw instants, numbers, amounts, and counts into text that reads right for each user with the browser's built-in Intl APIs, instead of hand-built strings that are correct for only one country.