Skip to content
SparkTime, home
System Design From Zero to Interview-Ready
Lessons
1 · System Design Foundations
2 · APIs and Communication
3 · Capacity Estimation
4 · Core Building Blocks
5 · Databases
6 · Distributed Systems
7 · Reliability and Operations
8 · Architecture and Security
9 · Interview Method and Foundation Case Studies
10 · Real-World Case Studies
11 · Large-Scale Case Studies
12 · Interview Readiness
Lesson 43 of 45

Lesson 43 · Large-Scale Case Studies

Design an E-Commerce System: Catalog, Cart, Checkout, Inventory and Flash Sales

Design an online store from product pages to delivery: a catalogue and search served from caches and event-fed indexes, carts in a key-value store, inventory reservations with expiry, a checkout saga across inventory and payment, oversell prevention, and a flash sale that survives a thousandfold spike with a waiting room, bucketed stock and isolation from normal shopping.

On this page

Key takeaways

  • An e-commerce platform is several systems with different needs: a read-heavy catalogue, a search index, per-user carts, strongly consistent inventory, and a checkout workflow across services.
  • Serve product pages and search from caches, CDNs and event-fed indexes; validate price and stock again at checkout.
  • Reserve inventory at checkout with an expiry, commit it when payment succeeds and release it on failure, so stock is neither oversold nor locked forever.
  • Checkout is a saga with idempotent steps; payment is the pivot, and every intermediate state is visible.
  • A flash sale is a traffic and contention problem: isolate it, admit buyers through a waiting room, spread stock across buckets or pre-allocated blocks, enforce per-user limits and defend against bots.

An online store looks like one product, but its parts behave like different systems. Millions of people browse product pages that change rarely. Search must understand "red running shoes size 9 under 3000". Carts are personal, written constantly and often abandoned. Inventory must never sell what is not on the shelf. Checkout must take money exactly once and reserve exactly the right stock. And once a year, a festival sale opens at midnight and a single phone model receives more requests in one minute than the whole store gets in a normal day.

This case study designs each part for its own needs and then puts them together, with special attention to the flash sale estimated in article 06: a thousandfold spike landing on one stock count. The design is generic and illustrative.

What you will learn

#
  • How to split an e-commerce platform into parts with different consistency and scale needs.
  • Catalogue, product pages and search at scale.
  • Cart storage and guest-to-user merges.
  • Inventory reservations with expiry and multi-warehouse stock.
  • The checkout saga and oversell prevention.
  • Flash sales: isolation, waiting rooms, bucketed stock and bot defence, with a runnable example.

1. The problem

#

Build the core of an online store (or a marketplace with many sellers): customers browse and search products, add them to a cart, check out with payment, and receive orders fulfilled from warehouses. The platform runs periodic flash sales of limited-stock items.

2. Clarify the requirements

#

Question

Assumed answer

Impact

Catalogue size?

50 million products (SKUs), many sellers

Search engine, document-shaped data

Traffic?

20 million DAU normally; much more during sales

Estimates below

Stock?

Several warehouses; stock per SKU per warehouse

Allocation at checkout

When is stock reserved?

At checkout, for 15 minutes while paying (not at add-to-cart)

Reservation lifecycle

Payments?

Through the payment platform (article 39)

Saga

Flash sales?

Yes: a few SKUs with small stock, opening at a fixed time to very high demand

Separate handling

Reviews, recommendations, ads?

Out of scope

n/a

Prices?

Can change; customers pay the price shown at checkout or are told it changed

Re-quoting

Functional requirements: product pages, search and filters; cart; checkout with payment; order history and tracking; inventory per warehouse; flash sales with per-user limits.

Non-functional requirements

Quality

Target

Product page and search latency

p99 under 300 ms

Checkout

Completes within a few seconds; never charges twice

Inventory correctness

Never oversell; reserved stock released within minutes if checkout fails

Availability

Browsing 99.99%; checkout 99.95%; a flash sale must not take down normal shopping

Freshness

Price and stock shown may lag by seconds; checkout uses current values

3. Assumptions and scale estimation

#
Code
20 million DAUProduct page views: 10 per DAU -> 200 million/day -> about 2,300/s average, about 7,000/s peakSearches: 3 per DAU -> 60 million/day -> about 700/s average, about 2,100/s peakCart updates: 1.5 per DAU -> 30 million/day -> about 350/s averageOrders: 2 million/day -> about 23/s average, about 70/s peakCatalogue: 50 million SKUs x 5 KB (attributes, descriptions) = about 250 GB; images in object storage + CDNFlash sale (from article 06): 1 million people, about 5 requests each in the first minute  -> about 83,000 requests/s on one product, for stock of, say, 10,000 units

Conclusions: normal traffic is read-dominated and highly cacheable; orders are modest; the flash sale is three orders of magnitude above normal for one SKU and must be treated as a separate problem.

4. API design

#
Code
GET  /v1/products/{productId}                    -> details, price, availability summary (cacheable)GET  /v1/search?q=running+shoes&size=9&max_price=300000&cursor=...GET  /v1/cart                                    -> items with current price and availabilityPUT  /v1/cart/items/{skuId}   {"qty": 2}         -> idempotent set-quantity (article 04)DELETE /v1/cart/items/{skuId}POST /v1/checkoutIdempotency-Key: 7c2e...{ "cart_version": 14, "address_id": "a_3", "quoted_total": 549800, "payment_method_token": "pmt_..." }-> 201 { "order_id": "o_88", "status": "pending_payment" } | 409 price or stock changed (re-quote)GET  /v1/orders?cursor=... ; GET /v1/orders/{orderId}POST /v1/sales/{saleId}/queue                     -> flash sale waiting-room token

5. Data model

#

Each capability owns its data (the database-per-service pattern (Database per service)):

Code
Catalogue service (document-friendly; source of truth for products):  products (product_id, seller_id, title, description, attributes JSON, images, category, status, version)  skus     (sku_id, product_id, variant attributes, price_paise, version)Search (derived): an index of products with text fields and filterable attributes, fed by catalogue eventsCart service (key-value): cart:{user_id} -> { items: [{sku_id, qty, added_at}], version }, TTL 30 daysInventory service (relational, strongly consistent):  stock        (sku_id, warehouse_id, on_hand, reserved, CHECK (reserved <= on_hand))  reservations (reservation_id, order_id, sku_id, warehouse_id, qty, status, expires_at)Order service: orders, order_items (prices copied at purchase), saga state, outboxPayment service: as in article 39

6. Baseline architecture

#

A single application and relational database serving pages, search via LIKE queries, carts in a table, and checkout in one transaction (insert order, decrement stock, call the payment provider inline). Fine for a small shop. At scale: product page reads overwhelm the database, LIKE search is slow and irrelevant, cart writes and order writes compete with browsing, the inline payment call holds a transaction open, and a flash sale melts everything at once.

7. What breaks first?

#
  1. Read volume on product pages and search.
  2. Search quality and speed: text relevance, filters and facets.
  3. Inventory contention on popular SKUs, and correctness across warehouses.
  4. Checkout across services: inventory, order and payment cannot share one transaction.
  5. Reservations that leak: stock reserved by abandoned checkouts.
  6. Flash sales: a spike on one row, plus bots.

8. Scaling the design

#

Catalogue and product pages

#
  • Product pages are mostly static: render the static part (title, images, description, specifications) to cacheable HTML or JSON served through a CDN (Amazon CloudFront), with versioned URLs or event-driven purges when a product changes (article 10).
  • Dynamic fragments (current price, "in stock", delivery estimate for the user's pin code) come from small, cached API calls, so the cacheable part is not personalised.
  • The catalogue service publishes ProductUpdated and PriceChanged events through an outbox; caches and the search index consume them.
#

A search engine such as Elasticsearch or OpenSearch indexes products with text fields, attributes and facets (Elastic's documentation describes how documents are organised in indices and made searchable (Index basics)). The index is a derived read model: built from catalogue events, sharded and replicated, a few seconds behind the catalogue. Popular queries are cached. Ranking blends text relevance with business signals (sales, ratings, availability). Article 44 explains inverted indexes in depth.

Cart

#
  • A key-value store keyed by user (or by a guest session id), with persistence, because losing carts loses sales (article 32's warning about caches as databases applies).
  • Each cart has a version, incremented on every change, so checkout can confirm it is acting on the cart the user saw.
  • On login, merge the guest cart into the user cart (sum or keep the larger quantity, by policy).
  • Cart pages show current price and availability, read from the catalogue and inventory caches; a cart is not a reservation.

Inventory and reservations

#

Stock is tracked per SKU per warehouse with on_hand and reserved. At checkout:

SQL
-- Reserve 2 units at a chosen warehouse, only if enough unreserved stock exists.UPDATE stock SET reserved = reserved + 2WHERE sku_id = :sku AND warehouse_id = :wh AND on_hand - reserved >= 2;-- 1 row: reserved. 0 rows: try another warehouse, or report out of stock.INSERT INTO reservations (reservation_id, order_id, sku_id, warehouse_id, qty, status, expires_at)VALUES (:r, :order, :sku, :wh, 2, 'held', now() + interval '15 minutes');
  • Commit on payment success: on_hand = on_hand - qty, reserved = reserved - qty, reservation committed.
  • Release on payment failure, cancellation or expiry (a scheduled job, article 37): reserved = reserved - qty, reservation released.
  • Every transition is conditional on the reservation's current status and keyed by order_id, so retries are idempotent.
  • Warehouse choice: prefer the warehouse nearest the delivery address that has stock; split orders across warehouses only when needed (a cost and delivery-time trade-off).

Reserving at checkout rather than at add-to-cart keeps stock available for people who actually buy; the trade-off is that a customer may find an item out of stock at checkout, which the UI must handle gracefully.

The checkout saga

#
Code
1. Order service: validate cart version, re-price, compare with the quoted total (409 if changed);   create order (pending_payment) + items + saga + outbox OrderCreated           [local transaction]2. Inventory: reserve each line (idempotent per order and SKU)                    [compensable: release]3. Payment: charge via the payment platform (idempotent per order)                [pivot]4. Inventory: commit reservations; Order: status confirmed                        [retryable]5. Fulfilment: create shipment; Notifications; Analytics (events)                 [retryable]Failures:  step 2 fails (out of stock): cancel order, tell the user which item  step 3 fails or is abandoned: release reservations, order payment_failed (user may retry within 15 minutes)  step 3 succeeds but step 4 fails temporarily: retry until it succeeds (after the pivot, never undo)

This is the saga pattern from article 21, with compensations before the pivot and retries after it (Saga pattern). The CHECK (reserved <= on_hand) constraint and conditional updates make overselling impossible even if application code has bugs.

Flash sales

#

A flash sale combines the ticket on-sale problem (article 38) with a hot counter. Five measures:

1. Isolate it. Run the sale's purchase path on separate capacity (its own service instances, its own inventory partition, its own queues) so a flood on one SKU cannot starve normal shopping. This is the Bulkhead pattern (Bulkhead pattern).

2. Serve the page from the edge. The sale page and product details are static and cached at the CDN; a countdown runs on the client. At opening time, most of the 83,000 requests per second are page loads the CDN absorbs.

3. Admit buyers through a waiting room. As in article 38, buyers get signed queue tokens and are admitted at a rate the purchase path handles. With 10,000 units and, say, 1 unit per buyer, there is no point admitting a million people at once.

4. Spread the hot counter. Split the SKU's stock across many bucket rows (or pre-allocate blocks of units to purchase servers) so concurrent decrements do not all queue on one row. A cached sold-out flag stops further attempts from touching the database at all. This Java program models 1,000 units split into 10 buckets and 1,500 purchase attempts: each attempt starts at one bucket and tries others if it is empty.

Java
// File: BucketedInventory.javapublic class BucketedInventory {    static final int BUCKETS = 10;    static final int[] remaining = new int[BUCKETS];    static final int[] touches = new int[BUCKETS];   // how often each bucket row was written or probed    static boolean soldOut = false;                   // in production: a cached flag every server checks first    // Mimics: UPDATE stock_buckets SET remaining = remaining - 1    //         WHERE sku = ? AND bucket = ? AND remaining >= 1    static boolean tryDecrement(int bucket) {        touches[bucket]++;        if (remaining[bucket] >= 1) {            remaining[bucket]--;            return true;        }        return false;    }    // Start at the bucket chosen for this request; if it is empty, try the others in turn.    static boolean buy(int startBucket) {        if (soldOut) {            return false;                             // rejected without touching the database        }        for (int i = 0; i < BUCKETS; i++) {            if (tryDecrement((startBucket + i) % BUCKETS)) {                return true;            }        }        soldOut = true;                               // every bucket is empty        return false;    }    public static void main(String[] args) {        int stock = 1_000;        for (int b = 0; b < BUCKETS; b++) {            remaining[b] = stock / BUCKETS;        }        int attempts = 1_500;        int sold = 0;        for (int request = 0; request < attempts; request++) {            int startBucket = (request * 7) % BUCKETS;   // stands in for a random choice            if (buy(startBucket)) {                sold++;            }        }        int left = 0;        int minRemaining = Integer.MAX_VALUE;        int maxTouches = 0;        for (int b = 0; b < BUCKETS; b++) {            left += remaining[b];            minRemaining = Math.min(minRemaining, remaining[b]);            maxTouches = Math.max(maxTouches, touches[b]);        }        System.out.println("attempts: " + attempts + ", sold: " + sold + ", rejected: " + (attempts - sold));        System.out.println("stock left: " + left + ", lowest bucket: " + minRemaining + " (never negative)");        System.out.println("busiest bucket row: " + maxTouches + " writes or checks"                + " (one stock row would have taken all " + attempts + ")");    }}
Code
attempts: 1500, sold: 1000, rejected: 500stock left: 0, lowest bucket: 0 (never negative)busiest bucket row: 101 writes or checks (one stock row would have taken all 1500)

Exactly the stock was sold, no bucket went negative, and the busiest row saw about a fifteenth of the traffic a single stock row would have. With real concurrency, the conditional WHERE remaining >= 1 is what keeps every bucket non-negative; the buckets only reduce how many requests wait on the same row.

5. Defend against bots and abuse. Per-account and per-payment-method purchase limits, verified accounts for sale participation, CAPTCHA or proof-of-work at the waiting room, and anomaly detection. OWASP lists unrestricted access to sensitive business flows, such as buying limited stock, among API security risks (OWASP API Security Top 10 2023).

After a successful decrement, the buyer has a short reservation to pay; unpaid units return to the buckets on expiry, so the sale can briefly "un-sell out", which the UI should explain.

The evolved architecture

#
Code
Browsers/apps --> CDN (product pages, images, sale page)             --> API gateway --> Catalogue (cached) --> events --> search index, page caches                             --> Search service --> search cluster (sharded, replicated)                             --> Cart service --> key-value store (persistent)                             --> Order service (checkout saga, outbox)                                     --> Inventory service (reservations; bucketed stock for hot SKUs)                                     --> Payment platform                             --> Flash-sale path (isolated): waiting room --> sale purchase service                                     --> sale inventory buckets --> same order sagaEvents --> fulfilment, notifications, analytics

9. Failure scenarios and reliability

#

Failure

Effect

Response

Search cluster degraded

Search slow or failing

Category browsing and cached popular queries still work; circuit breaker; degraded results

Cart store failure

Carts unavailable

Replicated, persistent store; apps keep a local copy to restore

Price changes during checkout

Customer would pay a different amount

Re-price at checkout; 409 with the new total for confirmation

Payment fails

Order cannot complete

Release reservations; let the customer retry within the reservation window

Reservation expiry job delayed

Stock looks unavailable longer

Availability calculations treat expired reservations as free; job catches up

Inventory DB primary fails

Checkouts fail briefly

Synchronous standby; checkout returns a retryable error; idempotency keys make retries safe

Flash-sale overload

Sale path saturated

Waiting room limits admission; normal shopping isolated by bulkheads

Duplicate checkout submissions

Possible duplicate orders

Idempotency key per checkout; cart version check

10. Consistency

#
  • Catalogue, search, page caches: eventually consistent (seconds). Displayed stock ("only 3 left") is approximate.
  • Cart: read-your-writes for its owner (single key, primary reads).
  • Inventory: strongly consistent per SKU and warehouse, enforced by conditional updates and constraints.
  • Orders: strongly consistent within the order service; cross-service state converges through the saga with visible intermediate states.
  • Money: handled by the payment platform's guarantees (article 39).

11. Security

#
  • Server-side pricing: totals are always computed on the server; the client's quoted total is only compared, never trusted.
  • Coupons and promotions: single-use codes redeemed with conditional updates (article 14); per-user limits; abuse detection.
  • Bots: on flash sales, inventory-checking scrapers and checkout bots; rate limits, waiting-room challenges, purchase limits.
  • Account takeover: login protection and step-up verification for address changes and high-value orders (article 28).
  • Personal data: addresses and phone numbers protected and visible to fulfilment partners only when needed.
  • Marketplace sellers: sellers can modify only their own products and see only their own orders (object-level authorisation).

12. Observability

#

Signal

Why

Conversion funnel: product view, add to cart, checkout start, payment success, order confirmed

Business health; drops reveal broken steps

Product page and search latency; search zero-result rate

Discovery experience

Reservation failures, expiries, leaked reservations

Inventory health

Oversell invariant (committed sales versus stock received)

Must never be violated

Checkout saga states by age

Stuck orders

Flash sale: queue size, admission rate, sold-out time, bot blocks

Sale operations

Event lag from catalogue to search and caches

Freshness

13. Trade-offs

#

Decision

Chosen

Alternative

Why

Service split

By capability, each owning data

One database

Very different scale and consistency needs

Product pages

CDN-cached static part, dynamic fragments

Fully dynamic

Read volume and latency

Reservation timing

At checkout, with expiry

At add-to-cart

Avoids locking stock for browsers; risk of checkout-time stock-outs

Checkout

Saga with payment as pivot

Distributed transaction

Services and provider cannot share a transaction

Flash sale

Isolated path, waiting room, bucketed stock

Scale the normal path

Contention and fairness cannot be scaled away

Search

Event-fed search engine

Database text search

Relevance, facets, scale

14. The final interview answer

#

"I split the store by capability. The catalogue owns products and publishes change events through an outbox; product pages are CDN-cached with small dynamic fragments for price and stock; search is a sharded, replicated search index built from catalogue events. Carts live in a persistent key-value store keyed by user, with a version number checked at checkout. Inventory tracks on-hand and reserved stock per SKU and warehouse in a relational store.

Checkout is a saga: the order service re-prices against the quoted total, creates the order with an outbox event, reserves stock with conditional updates and a 15-minute expiry, charges through the idempotent payment platform as the pivot, then commits the reservations and triggers fulfilment. Failures before payment release stock; failures after it are retried. A database constraint makes overselling impossible.

Flash sales run on an isolated path: static pages at the edge, a waiting room that admits buyers at a sustainable rate, stock spread across bucket rows with a cached sold-out flag so the hot SKU never queues on one row, per-account limits and bot defences. Normal shopping is protected by bulkheads, so a midnight sale can't take the store down."

Exercises

#

Exercise 1

When to reserve

#

Compare reserving stock at add-to-cart, at checkout start, and at payment success. For each, describe what can go wrong for customers and for the business.

Exercise 2: Plan a flash sale

#

10,000 units of a phone go on sale at midnight, 1 per customer. You expect 1.5 million interested people. Each admitted buyer takes about 2 minutes to complete payment, and about 20% of admitted buyers abandon. Design the admission rate so the stock sells within about 15 minutes, and describe what the 1.49 million people who do not get one experience.

Exercise 3: The coupon race

#

A coupon DIWALI500 can be used once per customer, and the promotion has a global budget of 100,000 redemptions. Design the redemption so neither limit can be exceeded under concurrent checkouts, and say what happens if the payment then fails.

Exercise 4: Two warehouses

#

An order contains item X (in stock only in Pune) and item Y (in stock in Pune and Bengaluru). The customer is in Bengaluru. Discuss the options for allocating stock and the trade-offs.

Show solutionHide solution
  • At add-to-cart: customers with full carts block stock for others; abandoned carts (very common) lock inventory for hours; easy to abuse (bots fill carts). Good only for very scarce items with short holds.
  • At checkout start (chosen): stock is held only for people actively paying, for a short window (for example 15 minutes). Risk: an item available while browsing is gone at checkout; the UI must handle it. Expired reservations return stock automatically.
  • At payment success: no holds at all, but two customers can pay for the last unit; one must be refunded after paying, which is a worse experience and costs refund fees. Acceptable only for items with plentiful stock.

Exercise 2

Show solutionHide solution

Each admitted buyer converts 80% of the time, so selling 10,000 units needs 10,000 / 0.8 = 12,500 admissions. Over about 13 minutes of admissions (leaving time for the last buyers' 2-minute payments within 15 minutes): 12,500 / 13 = about 960 admissions per minute, roughly 16 per second, which the isolated purchase path handles easily. As reservations from abandoned checkouts expire, admit more people to replace them.

The other 1.49 million: they get a waiting-room position immediately and honest messaging ("about 10,000 units for 1.5 million people; you are number 214,000"). When stock is exhausted (including expected abandonment), the waiting room tells everyone remaining that the sale has sold out, perhaps offering a notify-me option for restocks. Showing the true odds early prevents endless refreshing, which also protects the system.

Exercise 3

Show solutionHide solution

Two counters, both enforced atomically in the same transaction as creating the order:

SQL
-- per customer: a unique constraint prevents a second redemptionINSERT INTO coupon_redemptions (coupon, customer_id, order_id, status) VALUES ('DIWALI500', :c, :o, 'reserved');-- global budget: a conditional decrement (or bucketed counters if very hot)UPDATE coupon_budget SET remaining = remaining - 1 WHERE coupon = 'DIWALI500' AND remaining >= 1;-- if 0 rows: roll back (budget exhausted)

The unique constraint on (coupon, customer_id) stops one customer redeeming twice, even concurrently; the conditional decrement stops the global budget going below zero. If payment fails or the order is cancelled, the saga compensation releases both: mark the redemption released (allowing reuse) and increment the budget, conditionally and idempotently keyed by order_id. If the coupon is very popular, bucket the budget counter as in the flash-sale example.

Exercise 4

Show solutionHide solution

Options:

  1. Ship both from Pune (one shipment): simpler, one delivery, one packaging cost; Y travels further and arrives at the same (later) time as X.
  2. Split: X from Pune, Y from Bengaluru: Y arrives quickly, X later; two shipments cost more and may confuse the customer; it may be worth it if Y is time-sensitive.

Because Y is stocked in Pune as well, option 1 is already the consolidated shipment; no stock transfer between warehouses is needed. A typical rule: minimise the number of shipments unless that delays the order beyond the promised date; compute both options' delivery dates and costs and pick by policy. Reserve stock at the chosen warehouses atomically within the checkout saga; if one reservation fails (Pune sells its last X), fall back or report the item out of stock.

Where to go next

#

Sources & further reading

  1. learn.microsoft.com
  2. learn.microsoft.com
  3. elastic.co
  4. docs.aws.amazon.com
  5. api-security.owasp.org
  6. microservices.io
  • Guide · Technology

    Start Here: Your Roadmap from Zero to Job-Ready DSA

  • Guide · Technology

    Heaps and Priority Queues

  • Guide · Career

    A LinkedIn Profile That Gets Recruiter Messages