Home › High-Level Design › Design Amazon (E-commerce)
D

Design Amazon (E-commerce)

We are building the storefront and order path for a planet-scale retailer: browse a catalog, add to cart, and buy something without ever selling the same unit twice. I like this question because it hides three nasty problems behind one friendly buy button.

High-Level DesignHard14 min read

Get those three straight and the rest is plumbing.

If you remember five things

The whole answer in one glance. Everything below is the defense of these five calls.

  1. Split on the read:write seam. The catalog is a cache-and-CDN read machine (58K QPS); the order path is a small, strongly consistent write machine (175/sec). Spend your consistency budget only on the second one.
  2. One service owns oversell. Inventory is its own service with one invariant: available never goes negative. There is exactly one place the invariant can break, so exactly one place to get right.
  3. Conditional write, never read-then-write. Decrement stock with a single atomic statement guarded by available >= qty. The row lock serializes the race for free.
  4. Checkout is a saga. Reserve with a TTL, charge, commit, release or refund on failure, reconcile on crash. An idempotency key kills double submits.
  5. Price is frozen, not joined. Snapshot the price into the order line and re-read live price at checkout, so the browse cache can stay stale without ever deciding what you pay.

1Clarifying the requirements#

Before I draw a single box, I want to know what we are actually on the hook for. Skip this and I spend 40 minutes designing a search engine the interviewer never asked for. Here is roughly how I open with a candidate.

Candidate: Are we building the whole thing? Search, recommendations, payments, warehouse logistics, the lot?

Interviewer: No. Scope it to the shopping path: catalog browse and product pages, cart, inventory, checkout, and order creation. Treat payments and search as black boxes we call out to.

Candidate: One region or global? And do we care about the seller side?

Interviewer: Global, multi-region reads. Assume a single logical product catalog. Buyer side only. Sellers upload once, we ignore that flow.

Candidate: What is the one thing that must never break? If I had to pick a single invariant?

Interviewer: Never oversell. Charging a customer for a unit you cannot ship is the worst outcome. A product page being 30 seconds stale is fine. A double-sold PlayStation is not.

What those answers just told me:

  • The interview is about the oversell invariant and the read:write asymmetry, not CRUD.
  • "A product page 30 seconds stale is fine" is permission to cache the catalog hard.
  • "Never oversell" is the interviewer handing me the thesis. I write it at the top of the whiteboard and keep pointing at it.

Functional requirements

  • Browse a category and view a product detail page (price, images, stock status, reviews summary).
  • Add, update, and remove items in a cart that survives across devices.
  • Place an order: reserve stock, take payment, create an order record.
  • View order history and order status.
  • Reflect inventory changes (reserved, sold, restocked) accurately.

Non-functional requirements

  • Catalog reads: very high volume, low latency (p99 under 200 ms), can tolerate slight staleness.
  • Order writes: strongly consistent on inventory. No overselling, ever.
  • High availability for browse and cart. Checkout can be slightly slower but must be correct.
  • Durable orders and payments. We can replay and audit after the fact.
  • Horizontally scalable. Survive a single region or AZ failure without losing orders.
From the interviewer's chair

This opening is scored before you draw a single box. I am listening for whether you name the oversell invariant and the read:write gap on your own. Start sketching a product CRUD or a login flow and I have quietly downgraded you. Name the hard part first, then go spend your time there.

2Back-of-the-envelope estimation#

Let me put numbers on it so the design has something concrete to push against. The point is to find the one number that dictates the architecture, not to show off arithmetic.

The point of this section

You are not here to compute storage to three decimal places. You are here to surface one load-bearing number: reads dwarf order writes by hundreds to one. That asymmetry is what justifies caching the read path hard and spending the entire consistency budget on the tiny write path. Find it fast, then move on.

Users and traffic.

  • 300M monthly active users, 50M daily active.
  • Shopping is bursty (morning scroll, lunch, late night), so I plan for a peak concentration of about 5x over the naive average.

Read QPS (catalog and product pages).

  • Say each DAU does 20 product or catalog views a day: 50M x 20 = 1B reads/day.
  • Average is 1e9 / 86400, about 11,600 QPS.
  • Peak at 5x is roughly 58,000 read QPS. This is the firehose.

Cart writes.

  • Maybe 40% of DAU touch a cart, at 5 cart ops each: 20M x 5 = 100M/day.
  • About 1,160 average, so around 6,000 QPS peak.

Order writes.

  • Say 3M orders/day (6% of DAU convert): 3e6 / 86400, about 35 orders/sec average.
  • So around 175 orders/sec peak.
  • Each order has a handful of line items, so call it about 500 inventory decrements/sec at peak.

The asymmetry (this is the one that matters).

  • Reads outnumber order writes by roughly 300 to 1 (58,000 vs 175).
  • That single ratio is the entire thesis: cache the read path aggressively, spend the whole consistency budget on the tiny, precious write path.
  • Treat every read with the care an order needs and you buy 300x the strongly consistent machinery for no benefit.
~300:1
The ratio that drives the designCatalog reads vs order writes at peak (58,000 vs 175). It is why the catalog is cache-first and eventually consistent, and why only inventory and orders get the expensive strongly consistent path. Remember this one; it comes back in every trade-off.

Storage.

  • Catalog: 500M distinct products at about 5 KB of structured data each = 2.5 TB. Images live in object storage and a CDN, never this budget.
  • Orders: 3M/day x 365 x about 2 KB = roughly 2.2 TB/year, kept forever, so plan for tens of TB and growing.
  • Cart data is small and ephemeral.

Bandwidth.

  • 58K read QPS x 5 KB payload is about 290 MB/s out of the product service at peak, before the CDN soaks up most of it.
  • The CDN should serve 90%+ of image bytes and a good chunk of cached product JSON, so the origin sees a fraction of that.

3Core APIs#

Keep these boring and REST-ish on purpose. The interesting design lives behind them, not in the URL shapes.

GET  /v1/products/{productId}
GET  /v1/categories/{categoryId}/products?cursor=&limit=40
POST /v1/cart/items          { productId, qty }
PATCH /v1/cart/items/{id}    { qty }
POST /v1/checkout            { cartId, addressId, paymentMethodId, idempotencyKey }
GET  /v1/orders/{orderId}

The one worth staring at is checkout. Note the idempotencyKey.

  • A customer mashing the buy button on flaky hotel wifi must not create two orders and get charged twice.
  • The client generates a UUID once per checkout attempt and reuses it across retries.
  • The server dedupes on it via a unique index.
  • This single field prevents the most common real-world failure in a payment flow.
{
  "cartId": "c_8f21",
  "addressId": "addr_1023",
  "paymentMethodId": "pm_vault_55",
  "idempotencyKey": "chk_2f9c1e7a-0b44-4e3a"
}

Category browse uses cursor pagination, not offset:

  • Offset pagination on a large, shifting catalog gives duplicates or gaps when products are added or removed mid-scroll.
  • The cursor is an opaque token that resumes a stable range scan, so the server never re-counts from zero.

4High-level design#

The point of this section

The box diagram is table stakes. What earns points is the single seam: the catalog is a cache-first read machine, the order path is a transactional write machine, and they meet at exactly one place, inventory. Draw the boxes fast, then defend that seam.

The split follows the read:write asymmetry I found in the estimates:

  • The catalog path is a cache-first, eventually consistent read machine.
  • The order path is a careful, transactional write machine.
  • They meet at exactly one place: inventory.
flowchart LR U[Client] --> CDN[CDN Edge] CDN --> GW[API Gateway] GW --> PS[Product Service] GW --> CS[Cart Service] GW --> OS[Order Service] PS --> PC[(Product Cache)] PS --> PDB[(Catalog DB)] CS --> CR[(Cart Redis)] OS --> INV[Inventory Service] INV --> IDB[(Inventory DB)] OS --> ODB[(Order DB)] OS --> PAY[Payment Gateway] PS --> SRCH[Search Service] OS --> MQ[Event Bus] MQ --> SRCH MQ --> PC
The whole system at a glance. The catalog path (CDN, Product Service, cache) is tuned for the 58K-QPS firehose; the order path (Order, Inventory, Payment) is small and strongly consistent. They touch only at the Inventory Service, which is the one place the oversell invariant can break.

A few deliberate choices in that picture:

  • CDN and gateway up front. Product JSON and images are cacheable at the edge, keyed by product ID with a short TTL. Most reads never reach my own services, which is how 58K QPS stays affordable.
  • Product Service reads from a cache, not the DB. The Catalog DB is the source of truth, but the hot path hits a read-through cache. On a miss it reads the DB and fills the cache for the next caller.
  • Cart in Redis. Carts are small, high-churn, and do not need relational guarantees. Redis with a write-behind to a durable store so a node loss does not nuke carts. The key is the user or session ID.
  • Inventory is its own service with its own database. This is the crown jewel. Nobody writes stock counts except the Inventory Service, and it alone guards "never oversell." One service means exactly one place the invariant can be violated, so exactly one place to get right.
  • Order Service orchestrates checkout. It talks to Inventory (reserve), Payment (charge), and the Order DB (persist), and emits events on the bus.
  • Event bus fans out side effects: invalidate the product cache on a price or stock change, feed the search index, trigger emails and warehouse pick-lists. These are async and allowed to lag by seconds.

Why split it this way.

  • Reads and writes have opposite shapes: reads are enormous, latency-sensitive, and cacheable; order writes are tiny, precious, and need strict correctness.
  • The split scales reads to 58K QPS on cheap cache and CDN while keeping the small order volume on strongly consistent, more expensive machinery.
  • I am spending money where correctness lives and nowhere else.
Pause before you read on: why is giving inventory its own dedicated service (not just a table the Order Service writes) the single most load-bearing structural call here?
Reveal the answer
Because the invariant "never oversell" can only be violated at the moment stock is decremented. If every service could write stock, the race lives in N places and you can never fully reason about it. One service, one writer, one atomic guarded write: the invariant collapses to a single line of code and a single row lock. Reaching for this isolation unprompted is what signals you have actually run a system like this.

5Data model#

Use a few stores, each picked for its access pattern. The catalog is read-heavy and tolerant, so I would genuinely be happy with a document store (each product as one blob keyed by ID). I will show it relationally since it reads more clearly and the relationships matter once we get to the order side.

products

Field Type Notes
product_id UUID (PK) Stable, used as cache and CDN key
title text
description text
price_cents bigint Store money as integer cents, never float
currency char(3)
category_id UUID Indexed for category browse
version bigint Bumped on every edit; used for cache busting
updated_at timestamptz
  • Keyed by product_id so it doubles as the cache and CDN key, which keeps invalidation simple.

inventory

Field Type Notes
product_id UUID (PK) One row per SKU
available int Units free to sell
reserved int Units held for in-flight checkouts
sold int Lifetime sold counter
row_version bigint For optimistic concurrency
  • The invariant, enforced in both code and DB constraints: available >= 0 and reserved >= 0.
  • available can only be decremented if the result stays non-negative. That non-negative check is the oversell guard, and I enforce it inside the write itself (section 6a), not in application logic that can race.

carts (Redis, shown as logical shape)

Field Type Notes
cart_id string (key) Keyed by user_id or session_id
items map productId to qty Hash in Redis
updated_at ts TTL refreshed on every write

orders

Field Type Notes
order_id UUID (PK)
user_id UUID Indexed for order history
status enum PENDING, PAID, FAILED, CANCELLED, SHIPPED
total_cents bigint
idempotency_key string Unique index; dedupes double submits
created_at timestamptz

order_items

Field Type Notes
order_id UUID (FK)
product_id UUID
qty int
unit_price_cents bigint Snapshot price at purchase time

That unit_price_cents snapshot matters more than it looks.

  • If the seller changes the price after you buy, your order must still reflect what you agreed to pay.
  • I never join live to products.price_cents for a historical order. The price is frozen into the line item at purchase time.

6Deep dives#

6a. Never oversell: inventory reservation#

This is the heart of the problem, and it is where most candidates hand-wave.

  • The naive version is "read stock, check it is positive, write stock minus one."
  • That has a textbook race: two checkouts read available = 1, both pass the check, both decrement, and now you have sold 2 of 1.
  • Under 175 orders/sec with a hot item during a launch, that race fires constantly, not occasionally.

My rule: decrement under a conditional write, never a read-then-write. In SQL that is a single atomic statement:

UPDATE inventory
SET available = available - :qty,
    reserved  = reserved + :qty
WHERE product_id = :pid AND available >= :qty;
  • If zero rows are affected, there was not enough stock, and I fail the reservation.
  • The database row lock serializes concurrent decrements on the same product, so the invariant holds without any application-level locking or distributed lock service.
  • The WHERE available >= :qty clause is the entire oversell guard in one line.

But I do not take payment then decrement, nor decrement and hope payment works. I use a reservation with a TTL:

sequenceDiagram participant U as User participant O as Order Service participant I as Inventory Service participant P as Payment Gateway U->>O: POST checkout with idempotencyKey O->>I: reserve items I-->>O: reserved ok hold expires in 10m O->>P: charge card P-->>O: payment success O->>I: commit reservation O->>U: order confirmed
The checkout saga. Reserve moves stock into a hold with a TTL, then payment runs, then commit finalizes. If payment fails or the hold expires, a sweeper releases the stock back. The worst case is a unit briefly looks unavailable and then returns, which beats overselling or charging for nothing.

Step by step:

  • Reserve moves units from available to reserved with a hold expiry.
  • Charge the card.
  • On success, commit (move reserved to sold).
  • On payment failure, or if the hold expires, a sweeper releases the reservation back to available.

Two edge cases I call out before the interviewer does:

  • The commit must be idempotent. If my commit call times out and I retry, I cannot double-move stock. I tag each reservation with a reservation ID and record its state (RESERVED, COMMITTED, RELEASED), so a repeat commit on an already-COMMITTED reservation is a no-op.
  • Crash between charge and commit. I log the payment and reservation IDs to the Order DB before charging. A reconciliation job scans orders stuck in PENDING with a successful payment and finishes the commit. Money taken with stock not committed is a bug I can heal automatically; the reverse, stock gone with no money, is not. So I always order the steps to fail in the recoverable direction.
Red flag that fails you here

Reaching for a distributed lock or a Redis SETNX mutex to "protect" the stock count. That is slower, adds a dependency, and still races on the lock release. The row-level conditional write already serializes the decrement for free. Pull in a lock service for inventory and I read it as never having trusted the database to do its job.

6b. The hot product problem#

Big launch, one product, 20,000 people hammering the same product ID in the same second. Two things melt at once:

  • The product cache key (read side).
  • The inventory row (write side).
  • Both the thing I cached hard and the thing I guarded carefully now become single points of contention.

On the read side, a single cache key for a viral product is a classic hot-key problem. One Redis shard takes all 58K QPS for that key and falls over. Fixes, cheapest first:

  • Push that product's JSON to the CDN and to local in-process caches on each app node, so the Redis tier barely sees it.
  • For the rare miss storm, use request coalescing (single-flight): only one request per node goes to the DB on a miss, and the rest wait for that fill.
  • Replicate the hot key across multiple cache replicas and read from a random one.

On the write side, every order for the launch product contends on one inventory row. Row-lock serialization is correct but caps throughput at whatever one row can commit per second. If a single row becomes the bottleneck, I shard the inventory into N sub-buckets for that product:

flowchart TD R[Reserve request] --> H[Pick bucket by hash] H --> B0[(Bucket 0)] H --> B1[(Bucket 1)] H --> B2[(Bucket 2)] B0 --> AGG[Sum buckets for display] B1 --> AGG B2 --> AGG
Inventory bucketing for one hot SKU. The stock is split across N rows, so concurrent reservations hash to different buckets instead of contending on one row. Display stock is the sum across buckets, read off a replica. This buys roughly Nx reservation throughput on the hottest item.

How the bucketing behaves:

  • Each bucket holds a slice of the stock, say 10,000 units split into 10 buckets of 1,000.
  • A reservation hashes to a bucket and decrements there, spreading contention 10x.
  • If a bucket is empty, I fall through to the next one so I do not reject while stock still exists elsewhere.
  • Display stock is the sum across buckets, computed off the read replica.
  • This trades a little complexity and a slightly fuzzy "units left" number for 10x reservation throughput. I would only reach for it on known-hot launches, not every product, because it is not free.

6c. Cache invalidation on price and stock changes#

A stale price is a real liability, not a cosmetic bug.

  • If the cached page says 50 dollars and checkout charges 60, that is a trust problem and sometimes a legal one.
  • So reads are allowed to be stale on description and images, but price and availability need a tight story.

The key move: the cache never decides what you pay.

  • I version every product. The cache entry stores version.
  • The authoritative order path re-reads live price from the Catalog DB at checkout rather than trusting the cached page.
  • The browse page can lag, but the money is always computed from source of truth at the moment of purchase.
  • That sidesteps the hardest invalidation case entirely: I do not need the browse cache to be correct about price, only roughly current.

For the browse page itself:

  • When a price or stock state changes, the Catalog DB write emits an event on the bus.
  • A consumer invalidates (or better, proactively refreshes) the CDN and cache entries for that product ID.
  • TTLs stay short (say 30 to 60 seconds) as a backstop, so even a missed invalidation self-heals quickly.
  • Availability shows as coarse states ("In stock", "Only a few left", "Out of stock") rather than exact counts, which makes small staleness invisible and lets me cache harder.

7Bottlenecks and trade-offs#

Inventory DB is the scaling ceiling on writes.

  • Reservations are strongly consistent and contend on rows.
  • Order volume is small (175/sec) relative to reads, and I shard inventory by product ID so unrelated products never contend with each other.
  • Hot products get the bucket trick from 6b.
  • To go further, I would move inventory to a horizontally sharded store with per-key transactions.
  • I would not make inventory eventually consistent to chase throughput. Correctness is the product here, so that trades away the one thing I was told must never break.

Cache and source-of-truth skew.

  • My whole read path leans on the catalog being cacheable and tolerant of staleness.
  • The trade is that browse pages can show slightly old stock or price.
  • I accept it because checkout re-validates against source of truth, so the user is never charged a stale price.
  • The cost is a small rate of "added to cart, then it is gone at checkout" rejections, which I message clearly to the user.

Checkout is a distributed transaction across three systems (inventory, payment, orders), and there is no clean two-phase commit across a third-party payment gateway.

  • I use the saga pattern: reserve, charge, commit, with compensating actions (release the hold, refund) on failure, plus a reconciliation sweeper for crashes mid-flow.
  • The trade is eventual consistency on the order's final state for a few seconds, and the operational burden of running and monitoring the sweeper.
  • The alternative, holding a DB transaction open across a network call to the payment gateway, is a non-starter: it pins a connection for seconds and falls apart the moment the gateway is slow.

Cart durability vs speed.

  • Carts live in Redis for latency, which risks loss on node failure.
  • I write-behind to a durable store and accept that a hard crash might lose the last few seconds of cart edits.
  • Carts are low-stakes and users re-add. I would not pay the latency tax of a synchronous durable write on every cart tap.

Multi-region reads, single-region writes.

  • I serve catalog and cart from the nearest region with read replicas.
  • I pin inventory and order writes to a primary region (or shard-local primaries) to keep the oversell invariant simple.
  • Cross-region strongly consistent inventory is possible but expensive in latency. I would only build it if a single region genuinely could not hold the write load, which at 175 orders/sec it comfortably can.

Observability and SLOs.

  • SLOs as measured targets: product read p99 under 200 ms, checkout success rate, and reservation-to-commit latency.
  • Watch: cache hit ratio per tier, hot-key count, inventory row contention, reservation sweeper backlog, payment gateway error rate.
  • When a number moves, I already know which lever it maps to.

Spend your consistency budget only where the invariant lives, and cache everything else as hard as you dare.

8Summary#

The decisions that matter, tightest version:

  • Split the system along read vs write. Catalog is cache-and-CDN-first, eventually consistent, tuned for 58K read QPS. The order path is strongly consistent and small, at 175 orders/sec. The 300-to-1 ratio drives the whole shape.
  • Inventory is a dedicated service with one invariant: never oversell. Enforce it with conditional atomic decrements, not read-then-write, so one row lock serializes the race for free.
  • Checkout is a saga: reserve with a TTL, charge, commit, release or refund on failure, reconcile on crash. Idempotency keys dedupe double submits, ordered to fail in the recoverable direction.
  • Snapshot price into the order and re-read live price at checkout, so cached browse pages can stay stale safely and the cache never decides what you pay.
  • Hot items get special handling: CDN plus single-flight on the read side, inventory bucketing on the write side, both reached for only on known-hot launches.
  • Carts in Redis with write-behind durability, because they are low-stakes and latency-sensitive.
  • Out of scope by choice: search, recommendations, payments internals, warehouse logistics, seller onboarding. Naming them keeps me honest about what 45 minutes covered.
Follow-ups the interviewer will ask (and your answers)
"Two people check out the last unit at the same instant. Walk me through what happens."
Both hit the same conditional UPDATE guarded by available >= qty. The row lock serializes them: the first commits and drops available to 0, the second affects zero rows because the WHERE clause now fails, and I return it a clean out-of-stock. No application lock, no distributed mutex, no oversell. The database row lock is doing the entire serialization.
"The payment succeeds but the service crashes before it commits the reservation. Now what?"
I always log the payment and reservation IDs to the Order DB before charging, so the order sits in PENDING with a known-good payment. A reconciliation sweeper scans PENDING orders with a successful payment and finishes the commit, which is idempotent on the reservation ID. I order the steps so the only failure that strands state is the recoverable one (money taken, stock not yet committed), never the reverse.
"A product goes viral and 20,000 people load it per second. What melts, and how do you fix it?"
Two things: the single cache key (read side) and the single inventory row (write side). Read side I push the JSON to the CDN and in-process caches, add single-flight so one request fills on a miss, and replicate the hot key across cache replicas. Write side I shard that SKU's stock into N buckets so reservations hash across rows instead of contending on one, falling through empty buckets so I never reject while stock remains. Display count is the bucket sum off a replica.
"The browse page showed 50 dollars but you just raised the price to 60. Which does the customer pay?"
60, and correctly so. The browse cache is allowed to be stale on price because it never decides the charge. The order path re-reads live price from the Catalog DB at checkout, and the agreed price is then snapshotted into the order line. So caches stay cheap and eventually consistent while the money is always computed from source of truth.
"The customer double-taps buy on bad wifi. Two orders? Two charges?"
No. The client generates one idempotency key per checkout attempt and reuses it across retries. The server has a unique index on it, so the second request collides and returns the same order instead of creating a new one. That single field prevents the most common real-world double-charge.

What you can now explain in the room

  • Why a read-heavy storefront caches the catalog hard and spends its consistency budget only on inventory and orders.
  • How to derive the load: 58K read QPS at peak, 175 orders/sec, and the 300-to-1 ratio that drives the design.
  • Why a conditional atomic decrement beats any lock-based oversell guard, and how one row lock serializes the race.
  • How the checkout saga (reserve, charge, commit, release) stays correct through payment failures and crashes.
  • Why price is snapshotted into the order and re-read at checkout, so the browse cache can stay stale safely.
  • How a hot product is defended differently on the read side (CDN, single-flight) and the write side (inventory buckets).

For related flows, see the walkthroughs on designing a payment system, a distributed cache, and a URL-to-content CDN.

Get a prep plan built around your Design Amazon (E-commerce) interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design Amazon (E-commerce) prep plan is on the way.