Design an e-commerce marketplace
Open in the interactive app: practice, quiz, mock interview →In one minute
- Browsing and buying are two different systems. Browsing (product pages, search, recommendations) is read-heavy, latency-sensitive and allowed to be slightly stale. Buying (checkout, inventory, orders, payments) is low volume and must be correct. Split them early and give each its own consistency rules.
- The product page is an aggregation problem. One page pulls from dozens of services: catalog, offers, price, availability, delivery promise, reviews, ads. Fan-out amplifies tail latency, so every call needs a deadline and a fallback, and the page renders with whatever arrived in time.
- The cart must always accept writes. "Add to cart" is revenue. Amazon's Dynamo paper was written around exactly this: a cart store that stays writable during failures and merges divergent versions later.
- Availability shown is a hint; the reservation at checkout is the truth. The page can say "In stock" from a cache. Only a conditional decrement against a specific fulfilment centre's stock at order time decides whether the unit is yours.
- Placing an order is a saga, not a transaction. Reserve stock, authorise payment, persist the order, then hand off to fulfilment through events. Each step is idempotent and has a compensating action.
Requirements
Functional
- Browse and search a catalogue of hundreds of millions of products, each with offers from Amazon and third-party sellers.
- Show a product page with price, availability and a delivery promise ("Arrives Thursday") for the customer's address.
- Add items to a cart that survives across devices and sessions, including a guest cart merged on sign-in.
- Check out: choose an address and payment method, see the split into shipments, place the order exactly once.
- View order history and status; cancel before shipment.
Non-functional
- Product page p99 under 300 ms at the origin; search p99 under 200 ms.
- Add to cart available at 99.99% or better, even during a partial outage.
- No overselling beyond a small, reconciled tolerance; no double charge; no lost order once the customer sees the confirmation.
- Survive Prime Day peaks of roughly 10× normal traffic without a scramble.
Out of scope: seller onboarding, warehouse robotics and pick-pack, the recommendation models themselves, ads auctions, returns. Payments are covered in depth in Design Payment System and single-SKU stampedes in Design Flash Sale.
Back-of-envelope
Round assumed numbers, not Amazon's published figures.
- DAU: 100M. Each views 20 pages a day: 2B page views a day, or 2B / 86,400 ≈ 23K pages/s on average. Prime Day peak at 5× is about 115K pages/s.
- Internal fan-out: if a product page makes 40 backend calls, peak internal traffic is 115K × 40 ≈ 4.6M RPC/s. That number, not page views, sizes the service fleet.
- Search: 30% of page views start with a query: 600M a day, about 7K/s average and 35K/s at peak.
- Cart writes: 150M add or update operations a day ≈ 1.7K/s average, perhaps 20K/s at a deal launch.
- Orders: 10M orders a day ≈ 116/s average. Deals concentrate demand, so plan for 3K orders/s at peak. Each order touches about 3 inventory rows, so roughly 10K conditional writes/s.
- Catalogue storage: 500M items × 5 KB of attributes ≈ 2.5 TB, plus offers (2 per item on average, 500 B each) ≈ 0.5 TB. Small enough to replicate into several read-optimised stores. Images are separate: 500M × 6 images × 200 KB ≈ 600 TB in object storage behind a CDN.
- Order storage: 10M × 3 KB (order, lines, shipments, status history) ≈ 30 GB a day, about 55 TB over five years. With replicas and indexes, plan for 200 TB.
- Bandwidth: HTML of 120 KB × 115K/s ≈ 14 GB/s at peak from the origin tier, while images (perhaps 1 MB per page view) come from CDN edges at over 100 GB/s.
API
Public endpoints are REST behind an API gateway. Internal calls are RPCs with deadlines.
GET /v1/products/{asin}?zip=98109
-> { asin, title, images[], buy_box: { offer_id, seller, price_cents },
availability: "in_stock" | "low" | "out", promise: "2026-10-22" }
GET /v1/search?q=usb+c+cable&filters=brand:anker&cursor=...
-> { results: [ { asin, title, price_cents, rating } ], facets: {...}, next_cursor }
PUT /v1/cart/items { asin, offer_id, qty } -> { cart_version, items[] }
POST /v1/cart/merge { guest_cart_id } -> { cart_version, items[] }
POST /v1/checkout/preview { address_id, payment_method_id }
-> { checkout_id, shipments: [ { fc, items[], promise } ], total_cents, expires_at }
POST /v1/orders { checkout_id, idempotency_key }
-> 201 { order_id, status: "placed", shipments[] }
-> 409 { reason: "item_unavailable", asin } (preview again)
POST /v1/orders/{order_id}/cancel -> { status: "cancelled" | "too_late" }checkout/preview computes a plan and a total without reserving anything. POST /orders re-validates the plan, reserves, and is idempotent on idempotency_key, so a double-click or a retried mobile request returns the same order_id.
Data model
catalog_items is a document store keyed by asin. Attributes vary wildly by category (a TV has a panel type, a shirt has sizes), so a schemaless document fits. Partition by asin: every read is a point lookup.
offers is keyed by (asin, offer_id) and partitioned by asin, so the buy box can read every offer for a product from one partition. Price changes often, so it lives apart from the slow-changing catalogue item.
inventory is keyed by (sku, fc_id) with on_hand and reserved integers, partitioned by sku. Allocation needs all fulfilment centres for a SKU at once, so one partition holds them. A hot SKU gets its stock split into several buckets per centre (see Failure modes).
carts is a key-value item keyed by customer_id (or guest_cart_id) holding the item list and a version vector. Partitioned by owner; one read, one write.
orders is partitioned by customer_id with sort key created_at, because the dominant read is "my orders, newest first". The order_id embeds the customer's shard bits so a lookup by order ID routes directly. Rows: order header, lines, shipments and an append-only status history.
idempotency_keys maps (customer_id, key) to order_id with a TTL of a day, written in the same transaction as the order header.
outbox rows are written in the order transaction and relayed to the event bus (see Outbox Pattern), so "order placed" is published if and only if the order exists.
High-level design
flowchart LR
U["Browser / app"] --> CDN["CDN (images, static)"]
U --> GW["API gateway"]
GW --> PG["Page aggregator"]
PG --> CAT[("Catalogue store")]
PG --> OFF["Offers + pricing"]
PG --> AV["Availability cache"]
PG --> PR["Delivery promise"]
GW --> S["Search service"] --> IDX[("Search index")]
GW --> C["Cart service"] --> CS[("Cart KV store")]
GW --> CO["Checkout / order service"]
CO --> INV[("Inventory by SKU")]
CO --> PAY["Payment service"]
CO --> ORD[("Orders + outbox")]
ORD --> BUS["Event bus"]
BUS --> FUL["Fulfilment"]
BUS --> AV
BUS --> S
BUS --> NOT["Email / push"]Read path (product page).
- The CDN serves images and static assets. The request for the page reaches the page aggregator.
- The aggregator issues parallel calls with a shared deadline of about 150 ms: catalogue item, offers and price, availability for the nearest centres, delivery promise for the postcode, review summary, recommendations.
- Required parts (title, price, buy button) block the render. Optional parts (recommendations, reviews) render as placeholders filled by the client if they miss the deadline.
- Availability comes from a cache fed by inventory change events, a few seconds stale, which is fine for display.
Write path (place order).
- The order service checks
idempotency_keys; a known key returns the stored order. - It reloads the cart and recomputes prices; a changed price sends the customer back to preview.
- It runs the allocator to choose fulfilment centres, then issues conditional reservations:
reserved += qonly ifon_hand - reserved >= q. Any failure releases the reservations already taken. - It asks the payment service to authorise (not capture) the total.
- In one local transaction it writes the order header, lines, shipments, the idempotency key and an outbox row. The customer sees "Order placed".
- The relay publishes
OrderPlaced. Fulfilment, availability, email and search ranking consume it. Payment is captured when each shipment leaves the building.
Deep dives
1. The product page under fan-out
Options: wait for every dependency (the page is as slow as the slowest), precompute whole pages (impossible, since price and promise depend on the customer and the moment), or aggregate with deadlines and degrade. I choose the last, with a precomputed layer underneath: the catalogue item and its images are rendered into a cacheable fragment, while price, availability and promise are fetched live.
The arithmetic decides it. If each of 40 dependencies independently exceeds its own p99 1% of the time, the chance that at least one is slow on a given page is 1 - 0.99^40 ≈ 33%. So a page that waits for everything has a p67 equal to its dependencies' p99. This is not hypothetical: the Dynamo paper notes that one Amazon page typically called over 150 services, which is why Amazon wrote its SLAs at the 99.9th percentile rather than the mean (Dynamo, SOSP 2007). The fixes are a shared deadline, hedged requests for the few required calls (send a second copy after the p95 latency, take whichever returns first), and a strict list of what the page may render without. Hedging is cheap: in a Google benchmark, sending the backup request after a 10 ms delay cut the 99.9th-percentile latency of a 1,000-key read from 1,800 ms to 74 ms for only 2% more requests (Dean and Barroso, "The Tail at Scale"). Hedge only idempotent reads; never hedge a reservation or a payment call.
2. A cart that never says no
Options: a strongly consistent cart in a relational database (simple reads, but writes fail during a primary failover or partition), or an always-writable replicated cart that may diverge and is merged on read. The Dynamo paper chose the second for the shopping cart: writes go to any healthy replicas (sloppy quorum, hinted handoff), versions are tracked with vector clocks, and when a read finds siblings the application merges them by taking the union of items. The documented cost is that a deleted item can reappear, which customers tolerate far better than a failed "Add to cart". Divergence was rare in practice: over 24 hours, 99.94% of reads by the cart service saw exactly one version (Dynamo, SOSP 2007).
I would keep the semantics but implement them on a managed key-value store with conditional writes in a single region, and use version-vector merging only across regions. Don't assume the managed store gives you Dynamo's merge for free. DynamoDB, despite the name, replicates each partition through a Multi-Paxos group with a single leader rather than sloppy quorums (DynamoDB, USENIX ATC 2022), and its default multi-Region global tables resolve concurrent writes to one item by last writer wins (DynamoDB docs). Two Regions writing the same cart item would silently drop one add. So either store each cart line as its own item, so concurrent adds touch different keys, or keep a version vector in the item and merge in the application. Guest-to-signed-in merge is the same operation: union of lines, quantity = max of the two, then cap at the per-item purchase limit. The cart never reserves stock; holding inventory for 150M idle carts would starve real buyers.
3. Inventory, allocation and the order saga
Three questions get tangled here. What do we show? A cached, approximate count. What do we promise? A delivery date computed by an allocator that knows which centres hold the items, their distance and their carrier cut-off times. What do we commit? A conditional reservation per (sku, fc) row.
The allocator prefers one shipment from the nearest centre that can fill the whole order, because every extra box costs shipping and packaging. If no single centre can, it greedily picks the centre that covers the most remaining units, and repeats.
The order itself is a saga (Saga Pattern) rather than a distributed transaction: reserve, then authorise, then persist. If authorisation fails, release the reservations. If the order write fails after authorisation, void the authorisation. Reservations carry the order ID, so a sweeper can find and release any reservation older than ten minutes that has no order, which covers a crash between steps.
Variant: a multi-tenant store builder
Now suppose we are not Amazon, the one retailer, but the platform that lets millions of independent merchants run their own shops. The requirements shift from one catalogue and one inventory to millions of small ones, with the hard problem being isolation: one merchant's flash sale must not slow the other shops sharing the hardware.
The data model gains a tenant key. Every row carries a shop_id, and every request belongs to exactly one shop, resolved from the domain at the edge. A single shared database is one failure domain, and a database per shop is impossible to migrate, back up and connect to at millions of shops. The middle path is sharding by shop_id and grouping shards into pods, where a pod is a shard plus its own cache and job queues, and any unit of work touches exactly one pod. This is the cell pattern applied to tenants: a pod failure affects only its shops, and the rule "no cross-pod work" is what keeps it true. Shopify describes this design and its pod failover (Shopify Engineering).
Tenants grow unevenly, so pods drift out of balance, and Shopify reported its busiest and quietest shards differing by almost four times (Shopify Engineering). Balance by measured weight, not shop count, and make shops movable while live. The move has four steps: batch-copy the shop's rows in primary-key order, tail the row-based binlog filtered to that shop_id, take a brief exclusive lock when replay lag is seconds and replay up to the recorded position, then flip the routing entry and delete the old copy. Buyers see a few seconds of retried writes, not downtime.
The hot-spot also looks different. In our Amazon design, popular items are spread over many fulfilment rows. Here a single drop can send tens of thousands of requests per second to one shop, one pod and one inventory row. Scaling the database up is too slow, so shape the traffic instead: serve storefront pages from the edge cache so browsing never reaches the database, and give checkout a per-shop throttle sized from the commit rate of the hottest row, with a queue page and a retry-after hint for the rest. Per-shop limits on connections and job queues protect neighbours on the same pod.
Finally, the platform has a third audience: third-party apps. They call an Admin API under per-shop rate limits and receive webhooks at least once with a short response deadline, so consumers must verify signatures and dedupe on the delivery identifier. Cart and order semantics stay as designed above; what changes is who is allowed to hurt whom.
Down the stack
Layer 1The contract
- Place order. A
201means the order header, lines and outbox row are durable on at least two replicas, the reservations exist, and payment is authorised. The sameidempotency_keywithin 24 hours returns the sameorder_idand never reserves or authorises twice. - Reservation. For every
(sku, fc)row,0 <= reserved <= on_handalways holds. A reservation is released exactly once, by shipment, cancellation or the sweeper. - Display. Availability and promise on the product page may be up to about 30 seconds stale and are not a commitment.
- Cart. A write acknowledged to the client is never lost, but a concurrent removal may be undone by merge.
Layer 2The mechanism
The reservation is one conditional update. In SQL it is UPDATE inventory SET reserved = reserved + :q WHERE sku = :s AND fc = :f AND on_hand - reserved >= :q, and success is "1 row affected". In DynamoDB it is an UpdateItem with ConditionExpression: on_hand - reserved >= :q, which fails with ConditionalCheckFailedException. Multi-row orders either use a store-level transaction for the handful of rows or reserve row by row and compensate on the first failure. DynamoDB TransactWriteItems takes up to 100 actions and 4 MB, no two actions may touch the same item, and the whole request is cancelled if any condition fails. Its ClientRequestToken makes a retry idempotent for only 10 minutes, and reusing it with different parameters returns IdempotentParameterMismatch (DynamoDB API reference). That window is shorter than the order's 24-hour idempotency key, so the order service still needs its own key table.
The order write is one transaction on one partition (the customer's), containing the header, lines and outbox row. A relay tails the outbox, or the table's change stream, and publishes to the bus with the order ID as the message key, so consumers see each order's events in order and deduplicate by (order_id, event_seq).
Layer 3The machine
- A conditional write in a replicated store costs one leader round trip plus a synchronous copy to another availability zone. Inter-zone round trips are typically 0.5 to 2 ms, and an NVMe
fsyncof the write-ahead log about 0.1 ms, so a reservation lands in 3 to 10 ms end to end. - Row contention. While one update holds the row lock (or the leader serialises the item), others wait. At 2 ms per update, one row tops out near 500 reservations per second. A lightning deal on one SKU in one centre will exceed that, which is why hot stock is split into buckets.
- The page. 40 parallel RPCs, each a few KB of protobuf, cost little bandwidth but many connections; aggregators keep pooled HTTP/2 connections to each dependency so a call is one stream, not a TCP and TLS handshake (a full TLS 1.3 handshake adds one round trip, plus CPU for the key exchange).
Why it's built this way
- Reads outnumber purchases by about 200 to 1, so browsing is optimised for latency with caches and degradation, while buying is optimised for correctness.
- Pages fan out to many services, so tail latency compounds, so deadlines and optional sections are mandatory.
- A failed "Add to cart" loses revenue directly, so the cart favours availability and accepts merge anomalies.
- Stock is physical and scarce, so commitment happens only at checkout, as a conditional write on a narrow row.
- Inventory, payments and orders are owned by different teams on different stores, so the order is a saga with compensations instead of a cross-system lock.
Failure modes
- 10× load on Prime Day. Pre-scale from forecasts days ahead, pre-warm caches with deal pages, and shed optional work first: recommendations and reviews fall back to cached or empty, then search facets. Checkout and cart are the last to degrade. The real scale is large: during Prime Day 2025 DynamoDB peaked at 151 million requests per second for Amazon's own systems, and AWS ran over 6,800 fault-injection experiments beforehand to rehearse failures (AWS News Blog).
- Hot SKU. A deal sends 50K reservation attempts per second at one item. Split its stock into N buckets per centre, pick a bucket at random, and fall back to another bucket on failure. Put a queue or waiting room in front, as in Design Flash Sale.
- Payment provider slow. Authorisation latency jumps to seconds. Accept the order in a "pending payment" state with reservations held, authorise asynchronously, and email the customer to fix the payment method if it fails. The reservation sweeper bounds the damage.
- Region or zone outage. Run the order path in independent cells per region, each statically stable: capacity provisioned so that losing one zone does not require launching new instances during the incident, because the control plane you would call to launch them may be the thing that is impaired (Amazon Builders' Library). Carts replicate across regions and merge; orders stay in their home region and fail over by promoting a replica, accepting a few seconds of possible loss mitigated by client retries with the same idempotency key.
- Event bus backlog. Fulfilment falls behind. Orders are already durable, so the customer is unaffected; alert on lag, and process newest-first for time-critical consumers like email, per the Builders' Library advice on insurmountable backlogs (Amazon Builders' Library).
In the wild
- Amazon, 2007: the shopping cart that never refuses a write. The Dynamo paper describes a cart service that handled tens of millions of requests and over 3 million checkouts in a single day of the holiday peak. Writes went to any healthy replicas with hinted handoff, versions were tracked with vector clocks, and the cart merged divergent versions, accepting that a deleted item could come back. In a 24-hour profile 99.94% of reads saw a single version (Dynamo, SOSP 2007).
- AWS, 2025: one regional dependency empties its own DNS. In us-east-1 a delayed DynamoDB DNS Enactor applied a stale plan over a newer one, and the cleanup of old plans then deleted every IP address for the regional endpoint. DynamoDB errors lasted about 3 hours; EC2's lease manager depended on DynamoDB and fell into congestive collapse, so the whole event ran about 14.5 hours. AWS disabled the automation worldwide and added velocity limits and queue-based throttling (AWS).
- Ticketmaster, 2022: a single "SKU" meets bots. For the Eras Tour presale about 1.5 million fans got codes, but bots and fans without codes drove 3.5 billion system requests, four times the previous peak. Ticketmaster slowed and postponed sales to stabilise, and about 15% of interactions hit errors, including passcode failures that dropped carted tickets (Ticketmaster, archived).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Aggregate page with deadlines | Fast, resilient to slow services | Partial pages, more client logic | Many independent page sections |
| Fully server-rendered, wait for all | Simple, complete pages | Tail latency of the slowest call | Few dependencies |
| Always-writable cart, merge on read | Never rejects adds | Deleted items can return | Cart and wishlist |
| Strongly consistent cart | Exact semantics | Writes fail during failover | Small scale, single region |
| Reserve at checkout | Stock not locked by idle carts | Customer can lose the item late | General retail |
| Reserve at add to cart | Customer keeps the item | Starves buyers, needs expiry | Tickets, limited drops |
| Saga for order placement | Independent failures, clear repair | Compensations to write and test | Services owned by different teams |
| Distributed transaction (2PC) | Atomic across stores | Couples availability, blocking | Single database or tightly coupled stores |
Leadership lens
- Rollout: checkout changes ship behind a flag to a small slice of traffic and freeze weeks before Prime Day; game days rehearse zone loss and payment slowness.
- Build first: idempotent order placement with conditional reservations and the outbox, because a bug there costs money and trust. Next the page aggregator with deadlines, then search, then the delivery promise. Recommendations come last.
- Team ownership: catalogue, offers and pricing, search, cart, checkout and orders, inventory and allocation, payments, and the page aggregator are separate two-pizza teams, each owning its store and API.
- Cost drivers: CDN egress for images, the compute for page fan-out (millions of internal RPCs per second), the search cluster's memory, and replicated order storage. Order volume itself is cheap.
- Review questions: what is the oversell rate and who reconciles it; which page sections may be dropped and in what order; what happens to a reservation if the order service crashes between reserve and persist.
Check yourself
A product page calls 40 dependencies, each slow 1% of the time. Why does waiting for all of them hurt, and what does the design do instead?
Show answer
The chance at least one is slow is 1 - 0.99^40, about 33%, so the page's p67 equals its dependencies' p99. The aggregator uses a shared deadline of about 150 ms, hedges the few required calls, and renders optional sections as placeholders if they miss it.
Why does the cart use an always-writable, merge-on-read store instead of a strongly consistent relational cart, and what anomaly does that accept?
Show answer
A failed "Add to cart" loses revenue directly, and a strongly consistent cart rejects writes during failover or partition. Merging siblings by union means a deleted item can reappear, which customers tolerate far better than a rejected add.
The order service crashes after reserving stock but before persisting the order. How is the stuck stock recovered?
Show answer
Reservations carry the order ID, so a sweeper releases any reservation older than ten minutes that has no order. Placing an order is a saga with compensations, not a distributed transaction.
Why must a lightning deal on one SKU split its stock into buckets? Use the row-contention numbers.
Show answer
At about 2 ms per conditional update, one
(sku, fc)row tops out near 500 reservations per second, while a deal can send 50K attempts per second. Splitting stock into N buckets per centre, picking one at random and falling back on failure spreads that load across rows.
Sources and further study
Primary
- Dean and Barroso, "The Tail at Scale" (CACM 2013): why fan-out amplifies tail latency, and hedged and tied requests as the fix.
- Amazon DynamoDB: A Scalable, Predictably Performant, and Fully Managed NoSQL Database Service (USENIX ATC 2022): how DynamoDB departs from the original Dynamo, with Multi-Paxos replication groups.
- Shopify Engineering: A pods architecture to allow Shopify to scale: tenant sharding into isolated pods and live failover.
Systems docs
- DynamoDB TransactWriteItems: limits, cancellation reasons and the 10-minute client token.
- How DynamoDB global tables work: last-writer-wins in MREC, and the three-Region MRSC mode with no transactions.
- AWS: Prime Day 2025 key metrics: the measured peak load behind Amazon's retail systems.
Books
- Designing Data-Intensive Applications (1st edition), Martin Kleppmann, chapter 5 "Replication", section "Leaderless Replication": sloppy quorums, hinted handoff and merging concurrent writes, with the shopping cart as the example.
- Building Microservices (2nd edition), Sam Newman, chapter 6 "Workflow": sagas for order placement, using an e-commerce example (MusicCorp).
Other curricula
- system-design-primer: Design Amazon's sales ranking by category feature: frames e-commerce as a batch analytics problem over order logs, served through caches and read replicas.
- Azure Architecture Center: Compensating Transaction pattern: frames order cancellation as undo steps that must be idempotent and may themselves fail.
- Azure Architecture Center: Architectural approaches for storage and data in multitenant solutions: frames tenancy as shared, sharded or per-tenant data.
Watch
- AWS re:Invent 2018: Amazon DynamoDB Deep Dive: Advanced Design Patterns (Amazon Web Services): Rick Houlihan models order, inventory and customer access patterns in a single key-value table, the way retail teams at Amazon moved off relational databases.
Free resources
- Dynamo: Amazon's Highly Available Key-value Store (SOSP 2007): the shopping cart's requirements, sloppy quorums, vector clocks and the merge anomaly, from the source.
- Static stability using Availability Zones (Amazon Builders' Library): how to provision so a zone loss needs no emergency scaling.
- Making retries safe with idempotent APIs (Amazon Builders' Library): client request tokens and the semantics a retried "place order" needs.
- Avoiding insurmountable queue backlogs (Amazon Builders' Library): what to do when order events pile up faster than consumers drain them.
- Timeouts, retries and backoff with jitter (Amazon Builders' Library): picking deadlines for a fan-out page and avoiding retry storms.
- Pattern: Saga (microservices.io): choreography versus orchestration for a multi-service order.