The cart and checkout are the last two screens before money changes hands, and they want opposite things. The cart is busy and forgiving: millions of people add, remove and forget items, and the one unforgivable failure is an “Add to cart” button that does nothing. Checkout is quieter and strict: the total on “Place order” must be the amount charged, the phone must really be on a shelf, and a double tap on flaky 4G must still produce exactly one order and one charge. You will design both for a large Indian marketplace — 200 million shoppers a day, about 1,200 orders a second when a festive sale opens — and see why the two halves sit on opposite sides of the CAP trade-off.
Understand the problem
Agree on the boundary first, because three neighbouring designs touch this one. The online store owns the catalog, product pages and everything after an order is placed. The payment system owns talking to banks and card networks. You own the middle: from the first “Add to cart” to an order in the PLACED state, with stock held and payment authorised or cash on delivery accepted.
Functional requirements
- Users should be able to add, change and remove items in a cart — as a guest or logged in, from any device — and a guest cart should merge into their account when they log in.
- Users should be able to see an up-to-date total: prices, item promotions, coupons, bank offers, delivery charges and tax, recomputed as they change the cart, the address or the payment method.
- Users should be able to place an order: we hold the stock, take payment (or accept cash on delivery) and create exactly one order.
- The system should handle failures and abandonment: let users retry a failed payment, give stock back when payment never comes, and remind users about carts they left behind.
Non-functional requirements
- Cart writes always succeed. 99.99% available, p99 under 100 ms, surviving the loss of a zone. An edit on the phone may take a second or two to show on the laptop.
- Checkout is exact. No overselling, at most one order per checkout and one successful charge per order — and an automatic refund when the outside world forces a second. Stock, orders and coupon redemptions are strongly consistent.
- The price shown is the price charged, to the paisa, through a quote with an expiry.
- Latency. Cart page with totals under 300 ms at p99; “Place order” under 1 s at p99, not counting time in a UPI app or on a 3-D Secure page.
- Peak. About 23,000 cart writes and 1,200 orders a second when a sale opens — a cliff, not a ramp.
- Bounded holds. Unpaid stock goes back on sale within about 20 minutes.
- Durability. Carts kept for 30 days of inactivity; orders and holds survive the loss of any machine or zone.
In the interview
Say the split out loud, early: “The cart is AP — it accepts every write and sorts out conflicts later. Checkout is CP — it would rather say ‘please try again’ than sell the same phone twice.” Every later choice — the store, the locking, the retries — follows from that sentence.
Out of scope
- Catalog, product pages and stock badges — the online store; we read its offer and stock projections.
- Fulfilment after
PLACED— also the online store’s order pipeline. - Payment internals — PSPs, UPI and 3-D Secure, webhooks, reconciliation. We call the payment system’s create and confirm API.
- Flash sales of a few thousand units to millions of people — waiting rooms, anti-bot — in design a flash sale.
- Tax rates, promotion authoring and fraud models. Other teams own them; we evaluate the rules and call a risk service.
The set-up
Plan the approach
Draw the system as two halves joined by one handoff. The cart half takes most of the traffic and must never refuse a write, so it uses a partitioned key-value store and tolerates a second of staleness. The checkout half takes a small fraction of the traffic and must be exact, so it uses transactions, conditional writes, idempotency keys and timers. The handoff is the quote: an immutable, priced snapshot of the cart that checkout acts on. Build the four requirements simply, then spend the time on six deep dives: cart storage, pricing, stock, exactly-once orders, slow payments and the sale peak.
Core entities
- Cart.
c_9f3K: an owner (a user, or none for a guest), a state (ACTIVE,MERGED) and an optional checkout claim. No prices or totals; those are computed. - Cart line. An offer ID (one seller’s price for one product, as in the online store), a quantity, a version, and the unit price when added — so the cart can say “price dropped by ₹500”.
- Quote. An immutable priced snapshot: lines, every discount and why, delivery, tax per line, the total in paise, a hash of the cart lines it came from, its address and payment-method assumptions, an expiry and a content hash.
- Checkout.
chk_51Zp, one per attempt: cart, current quote, address, state and deadlines. Its ID is the idempotency key for everything downstream. - Stock hold. Offer, fulfilment centre (FC), quantity, checkout, state (
HELD,COMMITTED,RELEASED) andexpires_at. - Coupon redemption. A hold on a limited-use coupon, per coupon, user and checkout.
- Order.
ord_81Kq: lines copied from the quote, a payment and a state —PENDING_PAYMENT, thenPLACED, the first state of the online store’s pipeline.
API
Amounts are integers in paise, as in the payment system: ₹24,999 is 2499900. Add to cart carries a key generated per tap, so a retried tap is not a second phone:
POST /v1/carts/c_9f3K/lines
Idempotency-Key: 7d1e0c52-4b1a-4f0e-9a51-2f1f6c0b8a11
Content-Type: application/json
{ "offer_id": "of_7Q2", "quantity": 1 }
{
"cart_id": "c_9f3K",
"lines_hash": "9c1e04aa",
"lines": [
{ "offer_id": "of_7Q2", "title": "Phone Z5 (8 GB, 256 GB)", "quantity": 1, "unit_price": 2499900 },
{ "offer_id": "of_C11", "title": "Z5 silicone cover", "quantity": 1, "unit_price": 49900 }
],
"estimate": { "subtotal": 2549800, "currency": "INR", "binding": false }
}
The rest of the cart surface, sketched:
GET /v1/carts/c_9f3K lines with live price and stock, plus a non-binding estimate
PATCH /v1/carts/c_9f3K/lines/of_7Q2 { quantity: 2, if_version: 3 }
DELETE /v1/carts/c_9f3K/lines/of_C11
POST /v1/carts:merge after login: { guest_cart_id: "c_7Aa" }
Start checkout creates the checkout and its first quote. Each change of address, coupon or payment method returns a new quote:
POST /v1/checkouts
Content-Type: application/json
{
"cart_id": "c_9f3K",
"address_id": "adr_3",
"coupons": ["FEST500"],
"payment_method": { "type": "card", "issuer": "xyz_bank", "funding": "credit" }
}
{
"checkout_id": "chk_51Zp",
"quote": {
"quote_id": "q_51Zp_3",
"lines_hash": "b7f2310d",
"list_total": 2749700,
"discounts": [
{ "code": "SELLER20", "amount": 40000, "applies_to": "of_C77" },
{ "code": "FEST500", "amount": 50000, "applies_to": "cart" },
{ "code": "XYZBANK10", "amount": 150000, "applies_to": "cart", "requires": "card:xyz_bank:credit" }
],
"delivery": 0,
"total": 2509700,
"expires_at": "2026-10-27T20:15:00+05:30",
"hash": "sha256:4be1c07f…"
}
}
Place order names the quote and the total the shopper saw. The checkout ID is the idempotency key:
POST /v1/checkouts/chk_51Zp:place
Idempotency-Key: chk_51Zp
Content-Type: application/json
{ "quote_id": "q_51Zp_3", "expected_total": 2509700 }
{
"order_id": "ord_81Kq",
"state": "PENDING_PAYMENT",
"pay_by": "2026-10-27T20:17:00+05:30",
"payment": { "id": "pay_7Hq2", "status": "requires_payment_method" }
}
The app then confirms the payment directly with the payment system (UPI intent, card with 3-D Secure, netbanking) and waits on GET /v1/checkouts/chk_51Zp?wait=25, a long poll that returns when the order’s state changes.
Back-of-envelope estimates
Start from the online store’s numbers — 200 million daily shoppers, 10 million orders a day, sale peaks at 10× the daily average — and work up the funnel (method: back-of-envelope estimation).
Orders. 10M ÷ 86,400 ≈ 116/s on average; at 10× when a sale opens, about 1,200 orders/s.
Carts. Baymard’s average abandonment rate across 50 studies is 70.22%, so 10M orders need about 10M ÷ 0.3 ≈ 33M carts a day with at least one item.
Cart writes. About six per cart: 33M × 6 ≈ 200M a day ÷ 86,400 ≈ 2,300/s, so 23,000/s at the peak.
Cart reads. About four views per cart: 33M × 4 ≈ 132M a day ≈ 1,500/s, so 15,000/s at the peak. (The cart badge is not a read; the app keeps the count from its last write.)
Checkouts. If half of started checkouts become orders: 20M a day ≈ 230/s, so 2,300 starts/s at the peak, each re-priced about four times (address, coupon, payment method): 9,200 quotes/s.
Pricing. Every cart write and read returns an estimate: 23,000 + 15,000 + 9,200 ≈ 47,000 evaluations/s. At about 2 ms of CPU each that is 94 busy cores; at 50% target utilisation, about 190 cores — 24 eight-core pods.
Place order. Declines and abandoned UPI screens mean about 1.25 attempts per order: 1,200 × 1.25 = 1,500 calls/s.
Stock holds. At 2.5 lines per order: 1,500 × 2.5 = 3,750 line holds/s. Each costs about 3.5 row writes over its life (insert it and raise reserved; later commit it, or release it and lower reserved): 3,750 × 3.5 ≈ 13,000 writes/s. At the online store’s budget of about 2,000 synchronously replicated writes per primary, that is 6.5 primaries — 8 shards by offer ID.
Storage. About 500M carts (account carts plus 30 days of guest carts) × 1.5 KB ≈ 750 GB, 2.3 TB with three replicas. Live quotes: 9,200/s × 900 s × 3 KB ≈ 25 GB at the peak, which fits in memory.
Tip
Nothing here is big by storage standards. What shapes the design is the shape of the traffic: a huge, forgiving write stream on carts, and a small, unforgiving one at “Place order” whose hot spots — one popular phone, one popular coupon — are single rows.
High-level design
Build one requirement at a time. By the end of this section the design works for one region at moderate traffic; the deep dives make it correct under races and fast under a sale.
Users should be able to add, change and remove items in a cart
The app talks to a cart service through the API gateway. A guest’s first add creates a cart with a random 128-bit ID in an HTTP-only cookie; a logged-in user’s account points at one active cart, so the phone and the laptop share it. Carts live in a key-value store partitioned by cart ID, each line its own item next to a small meta item with the owner and state — so two devices adding different items never collide.
- The app sends
POST /v1/carts/c_9f3K/lineswith a fresh key for this tap. - The cart service writes only that line’s item, with a conditional write (first deep dive).
- Reads enrich the lines from the online store’s offer and stock projections, so the cart never stores anything that can go stale.
At login, the gateway calls :merge to fold the guest cart into the account cart.
Users should be able to see an up-to-date total
Add a pricing service: a pure function from a cart and its context — pincode, coupons, payment method — to a priced result. It reads offer prices from the store’s read model and evaluates promotions, delivery charges and tax from in-memory snapshots pushed when they change; at 47,000 evaluations a second it cannot query a database per request.
It runs in two modes with different contracts. The cart page gets an estimate: fast, not binding, allowed to be a few seconds stale. Checkout gets a quote: stored with an ID, a 15-minute expiry and a hash, and binding — it is what “Place order” charges.
Splitting estimate from quote lets the cart stay fast and forgiving while checkout stays exact: a stale snapshot can make an estimate briefly wrong, never a charge.
Users should be able to place an order
Starting checkout creates a checkout record, chk_51Zp, and its first quote. “Place order” sends the quote ID and the total the shopper saw, keyed by the checkout ID, and a checkout service orchestrates services that each own their data:
- Check the quote — unexpired, built from the current cart lines, total equal to
expected_total— and redeem a limited-use coupon. - Hold stock for each line at an FC that can deliver to the pincode, with an expiry.
- Create the order as
PENDING_PAYMENT, lines copied from the quote. - Create the payment for exactly the quote’s total, keyed by the order.
- The app confirms the payment — card with 3-D Secure, UPI intent, netbanking. For cash on delivery, a risk check replaces steps 4 and 5.
- On
payment.succeeded, the order becomesPLACED, the holds commit and the ordered lines leave the cart; the online store’s fulfilment takes over.
The system should handle failures and abandonment
Payments fail or never arrive — a declined card, a closed UPI app, a bank that takes minutes — and about 70% of carts are abandoned. Add payment events that report every outcome to checkout, timers that fire at each order’s pay-by deadline, a sweeper in inventory for holds nobody released, and a cart-events stream feeding an abandoned-cart detector that asks the notification service for reminders.
A declined payment does not fail the order: it stays PENDING_PAYMENT and the app offers another method, a new attempt with the same holds. Only pay-by, a cancel or a fraud rejection ends it, releasing the holds and the coupon; the cart keeps its items.
That design works, but it is not yet safe: two shoppers can race for the last unit, a retried tap can create two orders, and nobody has said what happens when a payment succeeds after its deadline.
Deep dives
How do we store carts so “Add to cart” never fails?
A cart write is cheap to get slightly wrong but expensive to refuse: a shopper whose “Add to cart” fails often doesn’t tap again. So this is an availability question.
Bad: the session or a cookie. Nothing to scale, but the phone and laptop see different carts, a deploy empties carts held in memory, and you cannot remind anyone about a cart you cannot see.
Good: a relational table sharded by cart ID. At 2,000 writes/s per primary, 23,000 writes/s is about 12 shards, and transactions make limits easy. But each shard has one primary, and failover typically rejects writes for 30 seconds or more: one shard of 12 down for 45 s at the peak is 23,000 ÷ 12 × 45 ≈ 86,000 failed adds.
Also good, differently: a leaderless, always-writeable store. The Dynamo paper was written for this: Amazon wanted shoppers to add items “even if disks are failing, network routes are flapping, or data centers are being destroyed by tornados”. Any of N replicas accepts a write, so writes almost never fail. The price: two writes that did not see each other create two versions, tagged with vector clocks — lists of (node, counter) pairs that tell “newer” from “concurrent” — and concurrent versions go back to the cart service to merge.
With a union merge, the paper says, an “add to cart” is never lost, but “deleted items can resurface”. A smarter merge records each removal as a tombstone for the adds it saw (an observed-remove set, a CRDT), at the cost of tombstones to collect and merge code every client must get right (clocks, ordering and conflict resolution). And conflicts are rare: over 24 hours, 99.94% of cart reads saw exactly one version, and divergence came mostly from busy robots. The machinery is forever; the conflicts are not.
Great: one leader per partition, one item per line, conditional writes. Many modern carts take the other bet: a partitioned key-value store in which each partition’s writes go through one leader replicated synchronously across three zones, with conditional writes on single items — the model DynamoDB offers as a managed service (Cassandra and DynamoDB) — and a data model that makes conflicts rare:
- One item per line, so two devices adding different products never touch the same item.
- Optimistic quantity changes:
SET qty = 2, v = v + 1only ifvis still 3, retried on conflict — DynamoDB’s documented optimistic locking (see optimistic vs pessimistic locking). - Adds deduplicated in the same write. DynamoDB’s docs warn that atomic counters are not idempotent — a retry can count twice — so the line keeps recent tap IDs and increments only for a new one:
UpdateItem key: cart#c_9f3K · line#of_7Q2
update: ADD qty :one, tap_ids :this_tap -- a one-element string set
condition: NOT contains(tap_ids, :tap) -- a retried tap changes nothing
AND (attribute_not_exists(qty) OR qty < :max_per_offer)
A leader election pauses a partition for seconds, which the app’s retry with the same tap ID absorbs; if even that fails, the app keeps the add locally and replays it later. Across regions, concurrent writes to one item are last-writer-wins — DynamoDB global tables do this, and its docs warn that version locking does not hold across Regions — but with one item per line that only matters for the same line edited in two regions within about a second.
What you gain over Dynamo is invariants: 10 per customer on a deal phone, a 500-line cap (Shopify’s cart API has the same limit and expires abandoned carts within 30 days), a claim that freezes the cart during checkout. What you pay is seconds of unavailability per partition during an election.
| Session or cookie | Sharded SQL | Leaderless (Dynamo) | Single-leader KV, item per line | |
|---|---|---|---|---|
| Same cart on every device | No | Yes | Yes | Yes |
| Writes while a node or zone fails | Lost with the server | Rejected during failover (30 s+) | Accepted by any replica | Paused for an election (seconds); retried |
| Concurrent edits | Not applicable | Row locks | Siblings merged by the app; deletes can resurface | Different lines never conflict; same line uses a version check |
| Invariants (caps, freeze at checkout) | Client-side only | Easy | Hard: replicas accept writes independently | Easy, per item |
| What you pay | Cookie size; no reminders | Failover gaps; resharding | Merge logic and clocks, forever | Last-writer-wins per line across regions |
Merging a guest cart at login. A guest fills a cart on the phone, then logs in at checkout; the account already holds last week’s cart from the laptop. Union the lines. For the same offer, keep the larger quantity, not the sum — it is usually one intent recorded twice. Apply the caps and say what was trimmed. And run the merge once: a conditional write flips the guest cart from ACTIVE to MERGED with a pointer, so two tabs logging in together, or a stale cookie, simply follow it.
How do we make sure the price at “Place order” is the price we charge?
A cart total is a pipeline of business rules that change under the shopper: a seller reprices, a coupon expires at midnight, a bank offer applies only to one bank’s cards. In Baymard’s surveys, extra costs are the most common reason for abandoning checkout once you set aside people who were just browsing. So: show the full total early, and charge exactly what you showed.
Bad: trust the client’s total. Anyone with a debugging proxy buys a phone for ₹1. Prices never come from the device.
Bad: re-price at “Place order” and charge the result. Tamper-proof, but the shopper may be charged an amount they never saw — the seller raised the price, or FEST500 expired while they typed an OTP.
Good: re-price and compare. Send the displayed total as expected_total; if the re-priced total differs, stop and show “Price changed from ₹24,999 to ₹26,499”. Correct, but it re-runs the engine at the busiest moment, and on a fast-moving deal the shopper can loop.
Great: an immutable quote from a fixed pipeline. Pricing always runs the same ordered steps, recording what each did and why:
- Unit prices from the offer read model — what the product page showed.
- Item promotions, such as seller discounts.
- Cart coupons, such as
FEST500: ₹500 off orders over ₹10,000. - Payment offers: “10% instant discount up to ₹1,500 on XYZ Bank credit cards”.
- Delivery, by pincode, weight and membership.
- Tax per line, from the product’s tax class and the seller and delivery locations, at a fixed point your tax team signs off — moving discounts before or after tax changes the total.
The bank offer works on the running total: 10% of ₹26,597 is ₹2,659.70, capped at ₹1,500, so the shopper pays ₹27,497 − 400 − 500 − 1,500 = ₹25,097.
The quote stores the lines hash it was built from, its assumptions (address, payment method), every discount and its rule, the total, a 15-minute expiry and a content hash. “Place order” names it, and the server checks that it exists, has not expired, still matches the cart’s lines hash and expected_total, and that the payment method still meets its conditions. The order copies the quote’s lines, and the payment is for exactly its total. Within 15 minutes the quote is honoured even if the seller raises the price — a short price lock agreed once, in writing — except for known pricing errors, where a kill switch invalidates live quotes for that offer. To verify quotes across regions without a shared store, sign them with an HMAC instead; the cost is that a signed quote cannot be revoked before expiry without a deny list (see encryption and key management).
Bank offers hang on the last choice. Choosing the payment method returns a new quote with the discount and its condition (requires: card:xyz_bank:credit). Checkout passes the condition to the payment system, which checks the card’s issuer and type from its BIN at confirmation; a different card is refused as “offer not applicable”, and checkout re-quotes. Record on each line who funds each discount — banks often share the cost — for settlement and refunds.
Limited coupons are inventory too. “FEST500, first 100,000 uses, once per customer” is redeemed at “Place order”, not at quote time, with a conditional increment (used < limit) and a unique row per coupon and user, and released by the same compensations as stock. A sale-wide coupon is a hot row; split its budget into buckets.
Allocate cart discounts to lines, in integer paise, so returning the ₹499 cover refunds what was actually paid for it. Naive rounding of ₹500 over lines of ₹24,999, ₹499 and ₹1,599 gives ₹461.29 + ₹9.21 + ₹29.51 = ₹500.01. The largest-remainder method fixes it: round each share down (₹461.28, ₹9.20, ₹29.50 = ₹499.98) and give the two leftover paise to the largest fractions — ₹461.29, ₹9.21, ₹29.50.
Keep it in memory. At 47,000 evaluations a second, rules are compiled into indexes by offer, category and seller, so a three-line cart checks a few dozen candidate promotions. Snapshot updates arrive in seconds, and each quote records its snapshot version so a disputed total can be replayed exactly.
A quote fixes the price, not the stock
Holding a price for 15 minutes is cheap: at worst you sell a few units at yesterday’s price. Holding a phone for 15 minutes for everyone who opens checkout is not, because half of them leave. Price and stock get different promises.
How do we avoid overselling without locking the catalog?
“In stock” on the product page came from a projection seconds old; only checkout knows. Stock is kept per offer per FC, as in the online store — on_hand on the shelf, reserved for checkouts, available = on_hand − reserved — and two shoppers can tap “Place order” for the last Phone Z5 at the Mumbai FC, BOM4, in the same millisecond.
Bad: read, then write. Both checkouts read available = 1 and both write reserved + 1: the lost update problem, two phones sold and one on the shelf.
Bad: lock the row for the whole checkout. SELECT … FOR UPDATE until payment completes oversells nothing, but a UPI payment takes minutes and every other buyer of that offer queues behind the lock. Never hold a database lock across human think time.
Good: a conditional update. One statement checks and changes: raise reserved only if on_hand − reserved is at least 1. The database serialises the two updates on that row, the loser sees zero rows updated, and the lock lasts a millisecond or two.
Two holes remain: a retry after a timeout reserves a second unit (an increment is not idempotent — DynamoDB’s docs warn about exactly this for atomic counters), and nothing gives the unit back if the shopper walks away.
Great: hold records with an expiry, one row per checkout and line, written in the same transaction as the counter:
BEGIN;
INSERT INTO holds (hold_id, offer_id, fc_id, qty, state, expires_at)
VALUES ('chk_51Zp:of_7Q2', 'of_7Q2', 'BOM4', 1, 'HELD', now() + interval '20 minutes')
ON CONFLICT (hold_id) DO NOTHING;
-- 0 rows inserted: this is a retry. Return the existing hold and stop.
UPDATE stock
SET reserved = reserved + 1
WHERE offer_id = 'of_7Q2' AND fc_id = 'BOM4'
AND on_hand - reserved >= 1;
-- 0 rows updated: ROLLBACK, then try the next FC that can deliver to this pincode.
COMMIT;
- Retries are free. The hold ID is checkout plus offer, so a repeat finds its own hold.
- Holds end by a conditional change from
HELD. Committed on payment: the units stay inreserveduntil picked, whenon_handandreservedboth drop. Released on failure or expiry:reserveddrops in the same transaction. A second release matches zero rows, so racing releasers are harmless. - Expiry needs a sweeper, not a TTL feature. DynamoDB deletes expired items “typically within a few days” — fine for cleanup, wrong for deadlines. Every few seconds, release due holds from an index on
expires_at, in batches withFOR UPDATE SKIP LOCKEDso sweepers never collide. If one hold in five expires, that is 3,750 × 0.2 = 750 releases/s at the peak. - Per FC, per line. Inventory ranks FCs that meet the delivery promise, nearest first, so a cart may split across FCs. Lines on different shards are held in parallel; if one fails, the others are released — a tiny saga inside the big one.
- Hot rows. Commits that wait for a synchronous replica (about 2 ms) cap one row at a few hundred holds a second: fine for ordinary products, not for a ₹9,999 deal phone at midnight. Split its stock into buckets (10 rows of 100 units, pick one at random) or send it down the flash sale path.
The other question is when to hold. At the sale peak:
- At add to cart, with a 30-minute expiry: 9,200 units added a second (40% of 23,000 cart writes) × 1,800 s ≈ 16M units held, mostly in carts that will be abandoned.
- At checkout start: half convert in about 3 minutes and half hold the full 15, averaging 0.5 × 180 + 0.5 × 900 = 540 s, so 2,300 × 2.5 × 540 ≈ 3.1M units.
- At “Place order”: four in five commit within a minute and one in five holds 20 minutes, averaging 0.8 × 60 + 0.2 × 1,200 = 288 s, so 1,500 × 2.5 × 288 ≈ 1.1M units.
| At add to cart | At checkout start | At “Place order” | After payment | |
|---|---|---|---|---|
| Units held at the peak | about 16M | about 3.1M | about 1.1M | none |
| Holds that end unused | most (about 70% of carts are abandoned) | about half | about 1 in 5 | none |
| Can it oversell? | No | No | No | Yes: two payments for the last unit, one refunded |
| What the shopper sees | Items “in cart” that others cannot buy | Stock that cannot vanish mid-checkout | A rare “sold out” at “Place order” | “Paid, then cancelled” |
| Use it for | Almost never — bots hoard | Seats, tickets, scarce drops | The default for retail | Digital goods without stock |
Hold at “Place order” for ordinary retail: the fewest units for the shortest time, at the cost of a rare “just sold out”. Seats and tickets, where a specific item vanishing mid-checkout is infuriating, hold earlier (see Ticketmaster and contention and double booking).
How do we create exactly one order and charge exactly once?
Duplicates come from everywhere: double taps, app retries after a 4G timeout, refreshes, two tabs, our own retries between services. At 1,500 calls a second, if 0.1% are retries of a request that already succeeded, that is 1.5 duplicate orders a second — 5,400 an hour, each a double charge to refund.
Bad: no key. Every retry after a timeout is a second order and a second payment.
Bad: a key made when the button is tapped. It changes with every page load: refresh or reopen the app, and the next tap is a new order. (A per-tap key suits “Add to cart”, where a second tap really is a second intent; “Place order” twice never is.)
Good: one key per checkout, minted by the server. chk_51Zp is created when checkout starts, stored server-side and sent as Idempotency-Key on every “Place order”; repeats get the first response, even after a refresh. Stripe’s API documents the same contract: the first response is saved and replayed, and a reused key with different parameters is rejected — which also catches a second tab that changed something. Stripe prunes keys after at least 24 hours; ours lives as long as the checkout, so even a late retry is recognised (see idempotency).
Scope the key down every layer so each service deduplicates on its own: holds are chk_51Zp:of_7Q2, orders are unique by checkout ID, redemptions are unique per coupon and user, and the payment’s key is the order ID. For two tabs, the quote check also claims the cart — a conditional write of claim = chk_51Zp only if no other checkout holds it — so the second tab hears “this cart is already being checked out”.
Great: a saga with a single owner. “Place order” changes data in four services — coupons, inventory, orders, payments — and two-phase commit with a bank is impossible: no PSP will hold a “prepared” charge for you. Use a saga: local transactions, each with a compensation that undoes it if a later step fails (2PC vs sagas). The microservices.io description of the pattern uses almost this example: an order created PENDING, then approved or rejected.
Order the steps on purpose: likeliest failures first (stock runs out more often than coupons), the irreversible step last. Payment is the pivot: before it every step has a compensation; after it the saga only moves forward, and the way back is a refund. Placing the order commits the holds, finalises the coupon, removes exactly the quoted lines from the cart (anything added since stays) and writes OrderPlaced through a transactional outbox.
Who runs the saga? With choreography, each service reacts to the previous one’s event and nobody owns the flow; with orchestration, one component directs each step and owns the deadlines.
| Orchestration (checkout owns the flow) | Choreography (services react to events) | |
|---|---|---|
| Who knows the whole flow | One workflow definition | Nobody; it emerges from subscriptions |
| Deadlines and timers | Natural: the orchestrator owns pay-by | Each service needs its own timers |
| Adding a step (gift cards, loyalty points) | Change one workflow | Change several services’ subscriptions |
| Debugging a stuck checkout | Read one workflow’s history | Trace events across five services |
| Coupling | The orchestrator knows every participant | Services know only event types |
| Best for | Short, ordered flows with a user waiting | Long, loosely coupled reactions such as fulfilment |
Choose orchestration: a person is waiting, the steps are ordered and there is a deadline. (The online store uses events after PLACED, where the flow is weeks long and loose.) Run it on a workflow engine such as Temporal or AWS Step Functions — AWS’s saga guidance builds exactly this place-order, update-inventory, make-payment flow — or on a checkout row with a step column and a timer table. The online store passed on an engine for 30-day order lifecycles, but checkouts live minutes: 1,500 a second × 120 s ≈ 180,000 open workflows, 1.8M if every one waits the full 20 minutes.
Two properties are mandatory, both on AWS’s list of saga considerations. Participants must be idempotent, because after a crash the orchestrator re-sends any step it cannot prove finished — hence every step is keyed by the checkout. And a saga has no isolation: others can see its intermediate state. The holds are the countermeasure, a semantic lock: reserved units are simply not available to anyone else.
What if the payment result is unknown? Creating the payment can time out; retry with the same key. Never treat “I don’t know” as “failed”: releasing holds while a payment may still succeed is how you charge someone for an order you threw away. The order stays PENDING_PAYMENT until the payment system resolves the attempt (its deep dive on timeouts covers how) or pay-by passes.
In the interview
Say where each duplicate dies: “The checkout ID is the idempotency key. It’s minted when checkout opens, so refreshes reuse it, and it’s scoped down every layer — the hold IDs, a unique constraint on the order, the payment’s key. A retry at any level finds the earlier result instead of making a new one.”
How do we handle slow payments, expiry and abandoned carts?
Most online payments in India wait for a person — a UPI PIN in another app, an OTP on the bank’s page — for ten seconds to several minutes, and some answers arrive after the shopper has given up. Checkout runs three clocks, and they must nest:
- Payment attempt: expired by the payment system after 10 minutes, and never allowed to run past pay-by.
- Order pay-by: 15 minutes after “Place order”.
- Stock hold: pay-by plus 5 minutes of grace for webhooks of payments that completed at the last second.
With attempt ≤ pay-by < hold expiry, a payment that succeeds in time always finds its stock.
Retrying a payment. After a decline, the order stays PENDING_PAYMENT and the app offers another method: a new attempt on the same payment with the same holds, allowed only if it can finish by pay-by. If the new method changes the price (the bank offer no longer applies), checkout re-quotes, cancels the old payment and creates one for the new total under the key ord_81Kq#2; the holds stay.
Expiry. When pay-by fires, checkout first asks the payment system for a final status — it may have succeeded a second ago. If nothing was paid, it runs the compensations; the cart keeps its items for another try.
Late success. A slow bank or a stuck UPI approval can still succeed after the deadline. Checkout tries to hold the same lines again; if they are available, the order becomes PLACED and the shopper never notices. If not, it refunds through the payment system, keyed by the order, marks the order REFUNDED and says so plainly: “Your ₹25,097 is being refunded; the phone sold out while your bank was confirming.” A jump in late successes means a slow PSP or deadlines that are too tight.
Abandoned carts. About 33M × 0.7 ≈ 23M carts a day are abandoned, and reminders must not scan the cart store.
- Bad: a nightly scan of carts idle for a day: hundreds of millions of rows read against live traffic, and reminders up to a day late.
- Good: a timer per cart, reset on every write and fired after an hour of silence. Simple, but up to 23,000 timer writes a second at the peak, nearly all overwritten.
- Great: a stream job with session windows. Cart events go to Kafka keyed by cart ID; a stream processor closes a cart’s session after an hour without events, and a
cart.checked_outevent closes it silently — the timer, without a timer store.
The notification service applies consent, quiet hours and a cap of two reminders (at one hour and 24 hours), and re-checks the cart before sending. If 30% of abandoned carts belong to logged-in, opted-in shoppers, that is about 7M reminders a day, about 80 a second. A reminder never quotes a price; it links to the cart, which shows today’s price — and “₹500 cheaper than when you added it”, made possible by the price-at-add on each line, is the best reminder there is.
How do we survive the first minute of a sale?
Festive sales do not ramp: at midnight traffic jumps 10× within a minute, and any queue you allow turns a 300 ms request into a timeout.
Bad: one fleet for cart and checkout. Cart traffic is about 15 times checkout’s. When shared pods saturate, “Place order” waits behind “Add to cart” and times out — you drop the requests that carry money.
Good: separate fleets and autoscaling. Cart, pricing and checkout scale independently, each with its own connection pools per dependency (bulkheads). But autoscaling reacts in minutes; the cliff takes seconds.
Great: pre-scale, admit only what the narrowest pipe can take, and degrade on purpose.
- Pre-scale to the forecast plus 50% an hour ahead, and warm the pricing pods with the sale’s rules and hot offers.
- Admit “Place order” at the rate of the narrowest downstream. If the PSPs together accept 2,000 payment creations a second and the hottest offer’s stock buckets take 3,000 holds, admit about 1,800 a second and give everyone else a “You’re in line — about 40 seconds” token. Admitting more only turns waiting into timeouts and orphaned holds. For 5,000 units and two million people, use the flash sale’s waiting room (and see backpressure and load shedding).
- Shed in a written order: cart-page recommendations first, then “price dropped” badges, then estimates fall back to the last total, marked “updating”. Never shed cart writes or “Place order” — they are the sale.
- Treat hot spots separately: stock buckets for the hot offer, a split budget for the sale’s coupon, per-user limits for bots.
Little’s law sizes the fleet: the synchronous part of “Place order” takes about 300 ms, so 1,800 calls a second means 1,800 × 0.3 ≈ 540 requests in flight — a few dozen pods. The minutes a shopper spends in a UPI app are not a request at all, just a PENDING_PAYMENT row, a timer and a long poll.
The final design
Put the pieces together. The cart half — cart service, cart store, pricing estimates — is wide, partitioned and always writable. The checkout half — checkout, inventory, orders — is narrow, transactional and driven by deadlines. Kafka carries what happened to everyone who reacts later.
Follow a shopper from the first tap to a placed order:
- Add to cart. One conditional write to that line’s item, deduplicated by the tap ID.
- See the total. The cart service enriches lines from the store’s read models; pricing returns an estimate from memory.
- Place the order. Starting checkout mints
chk_51Zpand a 15-minute quote. “Place order” passes admission control; checkout claims the cart, checks the quote and redeems any limited coupon. - Hold stock. Inventory writes a hold per line at the best FC; checkout creates the
PENDING_PAYMENTorder and the payment for exactly the quote’s total. - Pay. The app confirms with the payment system and long-polls checkout.
- Placed. On
payment.succeededthe order becomesPLACED, holds commit and the cart is trimmed; the outbox publishesOrderPlacedto Kafka for fulfilment. - Deadlines. At pay-by, checkout asks the payment system once more, then compensates; the sweeper catches leftover holds; late successes re-hold or refund.
- Abandonment. The cart store’s change stream feeds a session-window job that triggers capped, consented reminders.
Check it against the requirements: cart writes are single-item writes in a three-zone store, retried by tap ID, under 100 ms; totals come from memory; the quote fixes the charge to the paisa; conditional holds prevent overselling and the sweeper bounds unpaid holds at 20 minutes; the checkout-scoped key and unique constraints prevent duplicate orders and charges; and admission control keeps “Place order” within what payments and hot rows can absorb.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Scope | Cart CRUD and an orders table | Separates the forgiving cart from the strict checkout; treats payments and fulfilment as neighbours | States the AP/CP split, makes the quote the handoff, sets SLOs per half |
| Cart storage | One SQL table | Key-value store by cart ID; guest cookie; merge at login | Dynamo’s merge versus single-leader items per line; tap-ID dedupe; last-writer-wins across regions |
| Pricing | Adds up prices at the end | Re-prices at “Place order” and shows changes | Immutable quote with expiry and hash; bank offers tied to the payment method; discounts allocated in paise |
| Stock | Decrements when paid | Conditional update; reserves during checkout | Idempotent hold records, a sweeper, per-FC choice, hot-row buckets, “when to hold” arithmetic |
| Exactly once | Disables the button | Idempotency key on “Place order” | Key minted with the checkout and scoped down every layer; cart claim; saga with a pivot and compensations |
| Failures | A failed payment cancels the order | Timeouts and retries | Nested deadlines; unknown is not failed; late success re-holds or refunds |
| Peak | Autoscaling | Separate fleets and caching | Admission sized to the narrowest pipe, a shed order, Little’s law, the flash-sale path |
Follow-up questions
What if the customer pays cash on delivery?
A risk check replaces the payment steps: an order-value cap, whether the pincode allows COD, and the shopper’s history of refused parcels. Switching to COD re-quotes, because bank offers no longer apply and there may be a COD fee. If the check passes, the order goes straight to PLACED with payment due at the door, and the holds commit. Cash collection and returns of refused parcels belong to the online store’s order pipeline. A refusal costs two trips and a restock, so the risk check is scored, not a checkbox.
What if one cart has items from several sellers?
Keep one checkout, one quote and one payment; split the rest. Each line holds stock at its own FC, and the order has a sub-order per seller, shipped separately. If one seller’s line cannot be held, show it, re-quote without it and let the shopper continue. The per-line discount allocation is what lets a partial cancellation refund the right amount.
What if we run checkout for millions of merchants, like Shopify?
The platform-wide rate is similar: Shopify reported a Black Friday 2024 peak of $4.6M in sales a minute with an average cart of $108.56 — roughly 4.6M ÷ 108.56 ≈ 42,000 orders a minute, about 700 a second. The hot spots differ: one merchant’s product drop can dominate for minutes, so isolate merchants with per-shop admission limits and cells. Merchants also bring their own discount logic, which pricing must run under a strict time budget. Shopify’s cart API draws the same line as this design: a cart carries estimated discounts, tax and shipping, and checkout settles them.
What if a region fails in the middle of checkout?
Carts fail over and keep accepting writes, losing at most the last second of edits to last-writer-wins. A stock row, though, must never take holds in two regions at once, so each checkout shard has one primary region and a standby elsewhere; on failure the standby is promoted and in-flight checkouts resume from their workflow history. Payments that succeeded meanwhile arrive later as webhooks and take the late-success path. See multi-region and active-active.
What if bots fill carts with the sale’s hottest item?
Holding stock at “Place order” means a cart full of phones locks nothing. Per-offer caps apply on cart writes and again when holding, per-user rate limits apply to “Place order”, and the hottest items go through the flash sale’s admission path, which also deals with bots that pay.
How to say it in the interview
“I’ll design the cart and checkout for a large marketplace: about 23,000 cart writes and 1,200 orders a second when a sale opens. The two halves want opposite things, so I’ll design them differently: the cart is AP and must never refuse a write; checkout is CP and must never sell stock it doesn’t have or charge twice.
The cart is a service on a key-value store partitioned by cart ID, with each line as its own item. Different lines never conflict; quantity changes use version checks, and adds carry a tap ID so retries are harmless. Dynamo solved this with leaderless writes and union merges, but deleted items could come back; with one leader per partition and items per line, conflicts almost disappear and I can keep caps. Guest carts sit behind a cookie and merge at login, keeping the larger quantity.
Pricing is an in-memory pipeline — item promotions, coupons, bank offers, delivery, tax per line — giving an estimate for the cart and a stored quote with a 15-minute expiry for checkout. ‘Place order’ names the quote and the expected total, so we charge exactly what we showed.
Placing the order is an orchestrated saga keyed by a checkout ID minted when checkout opens: claim the cart, hold stock with a conditional update and a hold record that expires, redeem the coupon, create a pending order, then create the payment. Success commits everything; a missed 15-minute pay-by runs the compensations in reverse. Deadlines nest — attempt, pay-by, hold — so a payment that succeeds in time always finds its stock, and a late one re-holds or refunds.
For the sale, I pre-scale, admit ‘Place order’ only as fast as payments and hot stock rows can take it, shed recommendations before anything that carries money, and send the hottest deals to a flash-sale path.”
Concepts used in this design
- Idempotency — one checkout key, scoped down every layer.
- Cassandra and DynamoDB — the partitioned key-value store under the cart.
- Clocks, ordering and conflict resolution — vector clocks, merges and last-writer-wins.
- Optimistic vs pessimistic locking — version checks on cart lines.
- Lost update problem — why read-then-write oversells.
- Contention and double booking — conditional updates and holds for scarce stock.
- Distributed transactions: 2PC vs sagas — why “Place order” is a saga.
- Multi-step workflows — orchestrating steps, timers and compensations.
- Workflow engines — durable execution for short-lived checkouts.
- Transactional outbox — the placed order and its event commit together.
- Timeouts, retries and backoff with jitter — retrying safely when the outcome is unknown.
- Backpressure and load shedding — admission control and a shed order for the sale.
- CAP theorem and PACELC — an AP cart beside a CP checkout.
- Strong vs eventual consistency — estimates may be stale; quotes and holds may not.
- Flink and stream processing — session windows for abandoned carts.
- Design a notification system — consented, capped reminders.
References and further reading
- Dynamo: Amazon’s Highly Available Key-value Store (DeCandia et al., SOSP 2007)allthingsdistributed.comThe shopping cart as the motivating always-writeable service, vector clocks, union merges where deleted items can resurface, and 99.94% of cart reads seeing one version over 24 hours.
- Amazon DynamoDB Developer Guide: optimistic locking with version numberdocs.aws.amazon.comVersion-attribute conditional updates, and the warning that global tables resolve concurrent writes last-writer-wins, so version locking does not hold across Regions.
- Amazon DynamoDB Developer Guide: working with items (conditional writes, atomic counters)docs.aws.amazon.comConditional writes check the latest version of an item and are idempotent when they test the attribute being updated; atomic counters are not idempotent and can over- or under-count on retry.
- Amazon DynamoDB Developer Guide: time to live (TTL)docs.aws.amazon.comExpired items are deleted typically within a few days of their expiry time — fine for cleanup, wrong for giving stock back.
- Stripe API reference: idempotent requestsdocs.stripe.comClient-generated keys, the first response saved and replayed to retries, parameter comparison that rejects a reused key, and keys pruned after at least 24 hours.
- Pattern: Saga (microservices.io)microservices.ioSagas as local transactions with compensations, choreography versus orchestration, an order created PENDING then approved or rejected, and the missing isolation that needs countermeasures.
- AWS Prescriptive Guidance: saga orchestration patterndocs.aws.amazon.comA place-order, update-inventory, make-payment orchestration with reverse compensations; idempotent participants, semantic locks and the orchestrator as a single point of failure.
- Shopify developer docs: Cart (Storefront API)shopify.devCarts with estimated discounts, tax and shipping; abandoned carts expire within 30 days, completed carts are deleted, 500 lines maximum, sub-100 ms cart operations.
- Shopify: Black Friday–Cyber Monday 2024 datashopify.comPeak of $4.6 million in sales per minute and an average cart of $108.56, used to estimate platform-wide checkouts per second.
- Baymard Institute: cart abandonment rate statisticsbaymard.comA 70.22% average documented abandonment rate across 50 studies, and extra costs as the top fixable reason for abandoning.