Design an E-commerce Checkout

Design the cart and checkout of a large online store that sells physical goods from its own warehouses. Shoppers add items to a cart over days, then check out: the store confirms prices and stock, takes payment and creates an order that the warehouse will ship.

Most days the load is steady. A few times a year there is a flash sale: a limited batch of a popular product (say 10,000 game consoles) goes on sale at noon, and hundreds of thousands of shoppers press Buy in the same few seconds. The store must not sell more units than it has, must not charge anyone twice, and should keep the rest of the site working while the sale runs.

In 45 minutes, cover requirements and scale, the cart and checkout flows, how inventory is reserved and released, and how you survive the flash-sale spike.

Sketch on the HLD board, write your answer in the Design doc, and size it with the Estimator. Start the mock interview from the timer for a 45-minute run.

Design Path · Commerce & Payments · problem 30 of 70

Ask before you design. Try answering each yourself, then compare.

  1. Do guests have carts, or only signed-in users?
    Typical answer

    Both. Guest carts are tied to a device token and merged into the account cart on sign-in.

  2. When is stock taken: add to cart, checkout start, or payment?
    Typical answer

    Not at add to cart. Reserve at the start of the payment step with a short expiry; decrement for real when payment succeeds.

  3. What scale?
    Typical answer

    About 20 million daily shoppers and 2 million orders a day normally; a flash sale can bring 500,000 buyers for one SKU within a minute.

  4. Single warehouse or many?
    Typical answer

    Several warehouses; for this design treat stock per SKU per region and leave warehouse allocation to fulfilment.

  5. Who handles payment?
    Typical answer

    An external payment service with authorise/capture; we pass an idempotency key.

  6. How accurate must the stock shown on product pages be?
    Typical answer

    Approximate is fine ("only a few left"); the reservation step is the authority.

A cart and an order have very different durability and consistency needs. Do they belong in the same store?
If you decrement stock when an item goes in the cart, what happens to stock held in forgotten carts?
Write the single statement that reserves one unit and cannot oversell, even with thousands of concurrent callers.
During a flash sale, most buyers will fail. How can they fail cheaply, before reaching the database?
The shopper's browser times out during payment and they click Place Order again. What stops a second order?

Requirements

Functional

  • Add, update and remove cart items; carts persist for weeks and merge on sign-in.
  • Checkout validates prices, promotions and stock, and shows a final total.
  • Reserve inventory during payment, then commit on success or release on failure/expiry.
  • Place the order and emit it to fulfilment, email and analytics.
  • Run flash sales for limited-stock items without overselling.

Non-functional

  • Never oversell: committed orders for a SKU never exceed its stock.
  • Place-order p99 under 1.5 s excluding the payment provider; cart operations p99 under 100 ms.
  • Checkout available 99.99%; a flash sale must not degrade the rest of the store.
  • Absorb ~50,000 buy attempts per second on one SKU during a flash sale.
  • Exactly one order and one charge per checkout attempt, even with client retries.

Out of scope

  • Product search and recommendations.
  • Warehouse picking, shipping and returns.
  • Fraud scoring internals (treated as a black-box check).

Back-of-the-envelope estimates

  • Orders: 2 M/day ≈ 23 per second on average and ~100 at a normal evening peak; each order touches 2–3 SKUs.
  • Cart traffic: ~50 cart and pricing reads per order ≈ 100 M/day, ~1,200 per second average and a few thousand at peak.
  • Flash sale: 500,000 shoppers in the first minute for 10,000 units, with buyers clicking repeatedly, ≈ 50,000 attempts per second on one SKU. At most 10,000 can succeed, so 98% must be turned away quickly.
  • Storage: orders ≈ 2 M × 3 KB × 365 × 5 ≈ 11 TB (~33 TB replicated). Carts: ~50 M active carts × 2 KB ≈ 100 GB.

The flash-sale number drives the hard part: one row in one database cannot take 50,000 contended updates per second, so the spike must be filtered and serialised before it reaches the source of truth. Everyday order volume is small.

API

PUT  /v1/cart/items/{sku}        {qty}                  -> cart
GET  /v1/cart                                           -> {items, subtotal, warnings[]}
POST /v1/checkout                {cartVersion, addressId}  -> {checkoutId, total, expiresAt}
POST /v1/checkout/{id}/place     Idempotency-Key: <uuid>  {paymentMethodToken}
                                 -> 201 {orderId, status: PLACED} | 409 {outOfStock: [sku]}
GET  /v1/orders/{id}                                    -> {status, items, total}

Placing the order is keyed by the checkout id plus an idempotency key, so a double click or a retried request returns the existing order rather than reserving and charging again.

Data model

Carts (Redis with persistence, or a key-value store, keyed by cart_id): a hash of SKU → quantity plus a version. Carts are high-churn and losing one is annoying but not costly, so a fast key-value store fits; it is snapshotted to durable storage.

Inventory DB (relational, sharded by sku): stock(sku, region, on_hand, reserved) and reservation(reservation_id, checkout_id, sku, qty, expires_at, status). A check constraint reserved <= on_hand makes overselling impossible at the storage layer.

Orders DB (relational, sharded by order_id, with a by_user index): orders(order_id, user_id, checkout_id UNIQUE, status, total, payment_ref), order_lines, and an outbox for events. The unique checkout id is the last line of defence against duplicate orders.

Flash-sale counters (Redis): per-SKU token counters preloaded with the sale quantity, used as a fast gate in front of the inventory DB.

High-level design

Browsing and cart. Product pages and images come from the CDN. Cart changes go through the API gateway to the cart service, which reads and writes the cart store and re-prices on each read so the shopper sees current prices and stock warnings.

Placing an order. The checkout click reaches the order service with an idempotency key. It loads the cart, re-validates prices and promotions, and asks the inventory service to reserve each line. The inventory service runs one conditional update per SKU (reserved + qty <= on_hand) and writes a reservation with a 10-minute expiry, all in a single transaction on the SKU's shard (or a short saga across shards for multi-SKU carts). With stock secured, the order service authorises payment with the provider, then writes the order and its outbox event in one transaction and tells inventory to commit the reservation. If payment fails it releases the reservation; if the process dies midway, the reservation expiry job returns the units.

After the order. The outbox relay publishes OrderPlaced to the event stream, where fulfilment, email and analytics consume it independently.

Flash sale. For SKUs flagged as a sale, the gateway applies per-user rate limits and the inventory service first decrements a Redis token counter. Only requests that win a token proceed to the database reservation; the rest get a 'sold out' answer within milliseconds. Tokens from failed payments are returned to the counter.

Open the HLD board and press Reference to see the architecture diagram.

Deep dives

Reserving stock without overselling

Keep on_hand and reserved on the stock row and reserve with a guarded update: UPDATE stock SET reserved = reserved + :q WHERE sku = :s AND on_hand - reserved >= :q. Zero rows updated means not enough stock. Alongside, insert a reservation row with an expiry. Payment success commits it (on_hand -= q, reserved -= q); failure or expiry releases it (reserved -= q). Because the condition and the write are one statement, concurrent requests cannot both see the last unit. A sweeper releases expired reservations in batches, and the commit step re-checks that the reservation is still active, so a slow payment after expiry is voided rather than oversold.

Surviving a flash sale

50,000 attempts per second on one stock row would queue on its row lock and time out. Put cheap filters in front: a per-user limit at the gateway, then an atomic Redis DECR on a counter preloaded with the sale quantity. Winners (at most 10,000) go on to the normal database reservation, which now sees a few hundred per second. Losers hear 'sold out' immediately; when payment fails, the token goes back with INCR. If the counter and database ever disagree, the database guard still prevents overselling; the worst case is a few units left unsold until the sweeper reconciles. Run sale traffic on isolated service instances so regular shoppers are unaffected, and pre-warm caches for the sale page.

Multi-item carts across shards

A cart with three SKUs may touch three inventory shards. Rather than a two-phase commit, reserve line by line as a saga: each successful reservation is recorded on the checkout; if any line fails, release the ones already taken and return the out-of-stock lines to the shopper. Reserving in a fixed SKU order reduces lock contention between overlapping carts. Since reservations expire on their own, a crash in the middle of the saga cannot leak stock for longer than the TTL.

Idempotent order placement

The client sends an idempotency key with Place Order and the server derives a checkout id. The order row has a unique constraint on checkout id, the reservation ids are derived from it, and the payment provider receives the same key. A retry therefore finds the existing reservation (no new hold), the existing authorisation (no new charge), and the existing order (returned as-is). If the first attempt is still in flight, the second waits briefly on the idempotency record and returns its result.

Failure modes

  • If the payment provider is slow, reservations may expire; the commit step detects it, voids the authorisation and asks the shopper to retry.
  • If Redis flash-sale counters are lost, fall back to the database guard alone with stricter gateway rate limits; correctness holds, throughput drops.
  • If the order service crashes after payment but before writing the order, the recovery job finds authorisations without orders by idempotency key and completes or voids them.
  • If the inventory shard for a hot SKU fails over, reservations for that SKU fail briefly with a retryable error; other SKUs keep selling.
  • If the cart store is unavailable, show the last client-side copy of the cart and allow checkout from it after server re-validation.

Core algorithm in code

import random
import sys
import threading


class SkuStock:
    """Stock for one SKU. available = on_hand - sum(active reservations).
    In the database this is a row updated with
      UPDATE stock SET reserved = reserved + :q WHERE sku = :s AND on_hand - reserved >= :q
    plus a reservation row with an expiry; here a lock stands in for that atomic update."""
    def __init__(self, sku, on_hand, reserve_seconds=600):
        self.sku, self.on_hand, self.ttl = sku, on_hand, reserve_seconds
        self.reservations = {}                 # id -> (qty, expires_at)
        self.lock = threading.Lock()

    def available(self):
        return self.on_hand - sum(q for q, _ in self.reservations.values())

    def reserve(self, rid, qty, now):
        with self.lock:
            if rid in self.reservations:
                return True                    # idempotent retry
            if self.available() < qty:
                return False
            self.reservations[rid] = (qty, now + self.ttl)
            return True

    def commit(self, rid, now):
        with self.lock:
            r = self.reservations.get(rid)
            if r is None or r[1] <= now:
                return False                   # expired: payment must be voided
            del self.reservations[rid]
            self.on_hand -= r[0]
            return True

    def expire(self, now):
        with self.lock:
            gone = [rid for rid, (_, exp) in self.reservations.items() if exp <= now]
            freed = sum(self.reservations.pop(rid)[0] for rid in gone)
            return freed


# Flash sale: 100 consoles, 5,000 shoppers hit "buy" within a second.
stock = SkuStock("console-x", on_hand=100, reserve_seconds=600)
results = {"reserved": 0, "sold_out": 0}
rlock = threading.Lock()
reserved_ids = []


def shopper(i):
    ok = stock.reserve("r%d" % i, 1, now=0.0)
    with rlock:
        results["reserved" if ok else "sold_out"] += 1
        if ok:
            reserved_ids.append("r%d" % i)


if sys.platform == "emscripten":          # browser runtime: no threads, run attempts in turn
    for i in range(5000):
        shopper(i)
else:
    threads = [threading.Thread(target=shopper, args=(i,)) for i in range(5000)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()
print("5,000 shoppers ->", results, "| available now:", stock.available())

random.seed(4)
paid = [rid for rid in reserved_ids if random.random() < 0.85]   # 15% abandon at payment
for rid in paid:
    stock.commit(rid, now=120.0)
print("paid orders:", len(paid), "| on_hand:", stock.on_hand, "| still reserved:", len(stock.reservations))
print("10 minutes later, expiry job frees", stock.expire(now=601.0), "units back to sale")
late = [stock.reserve("late%d" % i, 1, now=602.0) for i in range(30)]
print("late shoppers who got one:", sum(late), "| oversold?", stock.on_hand - sum(q for q, _ in stock.reservations.values()) < 0)

Trade-offs

  • Reserving at checkout instead of at add-to-cart keeps stock available for real buyers but means a shopper can lose an item between cart and payment.
  • A Redis gate in front of the inventory DB absorbs flash-sale spikes but adds a second copy of stock that must be reconciled.
  • Short reservation TTLs return abandoned stock quickly but can expire during slow payments, forcing a void and a retry.
  • A saga across inventory shards avoids distributed transactions but costs compensating releases and brief partial holds.
  • Showing approximate stock on product pages saves database load but can show 'in stock' moments before 'sold out'.

What a strong answer shows

  • Separates cart (fast, soft state) from orders and inventory (transactional).
  • Reserves stock with an atomic conditional update and an expiry, committing only after payment.
  • Handles a flash sale with layered filtering (rate limits, token counter) before the database.
  • Makes order placement idempotent end to end, including the payment call.
  • Publishes order events through an outbox to downstream consumers.
  • Quantifies the flash-sale load versus normal load.

Mistakes to avoid

  • Decrementing stock when items are added to the cart.
  • Reading stock, checking it in application code, then writing, which oversells under concurrency.
  • Sending every flash-sale request straight to the inventory database.
  • Charging the card before reserving stock, then refunding many buyers.
  • Forgetting to release reservations on payment failure or abandonment.

Follow-ups to expect

  • How would you support pre-orders or back-orders? (Allow on_hand to go negative up to a limit, with an expected ship date.)
  • How do you apply coupons safely under concurrency? (Coupon redemptions as a guarded counter, like stock.)
  • How would you allocate stock across warehouses? (Reserve per region; fulfilment picks the warehouse later.)
  • How do you keep 'only 3 left' badges cheap? (Cached approximate availability updated from inventory events.)
  • What changes for a marketplace of third-party sellers? (Split orders per seller; stock owned by seller systems.)

Practice next

Next Post Previous Post
No Comment
Add Comment
comment url