← System design

Marketplace & Booking

Design Shopping Cart

A cart looks like a list with a plus button, but it sits between three systems it does not own: pricing, stock and checkout. On a sale morning 26,000 shoppers a second are adding, removing and switching devices, and every one of them expects the price on the review screen to be the price they pay. The design is about keeping the cart small and correct while those neighbours change underneath it.
Core: versioned cart document + checkout quote130K cart reads a second on sale daysWrite-safe, never lose an addGuest and account carts merged

01

What the cart must do

02

What the cart must do

Functional
  • Add an item (a specific SKU, so size and colour are already chosen), change its quantity, remove it, or move it to a saved-for-later list.
  • Work for guests with no account, using a cart ID in a cookie, and for signed-in users across phone, laptop and app.
  • When a guest signs in, merge the guest cart into the account cart without losing or doubling items.
  • Show current prices, applied promotions, estimated tax and shipping, and flag anything whose price or stock has changed since it was added.
  • Hand a consistent snapshot of the cart to checkout, which reserves stock and creates the order.
  • Emit events for abandoned-cart reminders and for analytics.
Non-Functional
  • Add to cart answers in p99 under 150 ms; viewing the cart, including repricing, under 300 ms.
  • An acknowledged add is never lost, even if a node fails a second later.
  • Stay writable at 99.95% during sales, when traffic is ten times normal.
  • A user editing from two devices sees one cart, and edits made a moment apart both survive.
  • Never charge a price the shopper was not shown at the final review step.
Questions to settle first

Ask whether adding to the cart should hold stock. For a normal retailer the answer is no, and that one decision removes a huge amount of complexity. Also ask how long guest carts live, whether there is a per-item quantity limit, and whether the cart has to work across country storefronts with different currencies.

02

Sizing the load

03

Sizing the load

Assume a large marketplace with 120 million monthly shoppers, of whom 25 million touch their cart on a normal day. A typical session makes about nine cart changes and views the cart about five times per change. Sale days run at ten times the normal peak.

QuantityWorkingResult
Shoppers who edit a cart per dayassumption25M
Cart writes per day25M x 9 changes225M
Average write rate225M / 86,400 sabout 2,600 / s
Sale-day peak writes10x average, concentrated in the first hourabout 26,000 / s
Cart reads per day5 reads per write, badge plus cart pageabout 1.1B
Sale-day peak reads10x average read rateabout 130,000 / s
Carts kept90 days for accounts, 30 days for guestsabout 300M
Size of one cart record6 lines on average x 180 B, plus header and metadataabout 1.5 KB
Cart store size300M x 1.5 KB, then 3 replicas450 GB raw, about 1.4 TB stored
Repricing calls on read130K reads / s x 6 lines, batched per cartabout 130,000 batched calls / s
What actually limits you

The data is small: well under 2 TB fits comfortably in any partitioned key-value store. The pressure comes from repricing on every read and from sale-day spikes, where a single popular product can appear in millions of carts in the same ten minutes.

A shortcut you can take

The cart badge in the header only needs a count. Store the item count in a signed cookie updated on every write, and the most common read never touches the backend.

03

API design

04

API design

POST/v1/carts/{cart_id}/linesAdd a SKU or increase its quantity
// Cookie: cart_id=c_8h2kq9 (guest) or Authorization: Bearer <user token> // Idempotency-Key: 7a1e0c, a retried tap does not add the item twice { "sku": "SHOE-RUN-4471-42-BLK", "quantity": 1, "seller_id": "sel_2210", "seen_price": { "amount": 459900, "currency": "INR" } // price on the product page, in paise } // 200 OK { "cart_id": "c_8h2kq9", "version": 14, "item_count": 3, "line": { "line_id": "ln_51", "sku": "SHOE-RUN-4471-42-BLK", "quantity": 1 } }
PATCH/v1/carts/{cart_id}/lines/{line_id}Change quantity or move to saved for later
// If-Match: 14, the version the client last saw { "quantity": 2 } // 200 with the new version, or 412 if the cart changed elsewhere // The client then refetches and replays the edit
GET/v1/carts/{cart_id}?view=pricedCart with live prices and warnings
{ "version": 15, "lines": [ { "line_id": "ln_51", "sku": "SHOE-RUN-4471-42-BLK", "quantity": 2, "unit_price": 429900, "added_at_price": 459900, "notices": ["PRICE_DROPPED"], "stock": "LOW" // IN_STOCK, LOW or OUT } ], "promotions": [{ "code": "AUTUMN10", "discount": 85980 }], "subtotal": 773820, "quote_id": "q_c8h2kq9_15", // ties checkout to exactly this view "quote_expires_at": "2026-10-01T10:45:00Z" }
POST/v1/carts/mergeRun after a guest signs in
{ "guest_cart_id": "c_8h2kq9", "merge_token": "mt_44f0" // issued at login, makes the merge safe to retry }

Two details are worth pointing out. Writes carry an idempotency key, because mobile networks retry and a double tap should not mean two pairs of shoes. And the priced view returns a quote ID, so checkout can prove which prices the shopper saw.

04

Architecture overview

05

Architecture overview

Cart service and its neighboursDiagram
flowchart LR
	C["Web and app clients"] --> GW["API gateway"]
	GW --> CS["Cart service"]
	CS --> DB[("Cart store, KV by cart_id")]
	CS --> PR["Pricing and promotions"]
	CS --> INV["Inventory read API"]
	CS --> ID["Identity service"]
	CS -. "cart events" .-> K[["Event stream"]]
	K --> AB["Abandoned cart worker"]
	K --> AN[("Analytics")]
	GW --> CO["Checkout service"]
	CO --> CS
	CO --> RES["Stock reservation"]
	CO --> ORD[("Orders")]

The cart service is stateless and owns exactly one thing: the list of lines a shopper intends to buy. It does not own prices, stock or orders. Every cart lives as one record in a key-value store keyed by cart_id. Account carts and guest carts share the same table; a separate small index maps user_id to the user's active cart ID.

Reads are where the work happens. When the shopper opens the cart page, the service loads the record, then makes one batched call to pricing for all lines and one to the inventory read API for availability. It compares current prices against the price each line was added at and attaches notices. If pricing is slow, it returns the cart with last known prices and a flag rather than blocking the page.

Checkout is a separate service that asks the cart for a snapshot at a given version, reserves stock, takes payment and writes the order. Splitting them keeps the cart simple and lets checkout be the place where correctness is enforced. Every cart change also goes onto an event stream, which drives abandoned-cart emails, recommendations and funnel analytics without touching the request path.

Cart store

DynamoDB, Cassandra or a sharded Postgres all work. You need single-digit-millisecond reads by key, conditional writes on a version number, and per-item TTL to expire old guest carts.

Pricing and promotions

Owned by another team. Our contract is a batch endpoint that takes a list of SKUs plus quantities and returns line prices and applicable promotions in one round trip.

Inventory read API

A cached, slightly stale view of stock good enough to say "only 2 left". It is not the reservation system, which checkout calls directly.

Event stream

Kafka or Kinesis. Consumers are allowed to be minutes behind, because nothing on the shopper's screen waits on them.

05

Deep dive: the cart record, concurrent edits, merging and checkout

06

Deep dive: the cart record, concurrent edits, merging and checkout

The real questions

Storing a list of SKUs is easy. The interview is about three moments: two edits arriving at the same time, a guest cart meeting an account cart at sign-in, and the handoff to checkout, where prices and stock must be pinned down.

One record per cart

Keep the whole cart in a single record rather than one row per line. A cart rarely has more than a few dozen lines, so the record stays small, every read is one key lookup, and every write can be guarded by a single version number. Row-per-line looks more normalised but turns "show my cart" into a range query and makes atomic multi-line edits, such as a merge, harder.

{ "cart_id": "c_8h2kq9", "owner": { "type": "user", "id": "u_77120" }, "version": 15, "currency": "INR", "lines": [ { "line_id": "ln_51", "sku": "SHOE-RUN-4471-42-BLK", "qty": 2, "added_price": 459900, "added_at": "2026-09-30T18:02:11Z", "op_ids": ["op_9c", "op_a1"] } ], "saved_for_later": [], "recent_ops": ["op_9c", "op_a1", "op_a7"], // last 50 idempotency keys "expires_at": "2026-12-29T00:00:00Z" }

Two edits at once

A shopper adds a jacket on the phone while the laptop, open in another room, removes a scarf. Both requests read version 15. Without care, whichever write lands second overwrites the first and an item silently reappears or vanishes.

Every write is a conditional put: "replace this record only if version is still 15". The loser gets a conflict, rereads the record, reapplies its own operation to the fresh copy and tries again. Because operations are small and expressed as intents, such as "add one of SKU X" or "set line Y to quantity 0", replaying them on newer state is safe. Conflicts are rare; even on sale days, two writes to the same cart within the same 20 ms are unusual.

Retries from flaky networks are a different problem. The client sends the same idempotency key on a retry, and the record keeps the last 50 applied keys in recent_ops. If the key is already there, the service returns the current cart without reapplying the change.

Merging at sign-in

Guest to account mergeDiagram
flowchart TD
	L["Guest signs in"] --> T["Identity issues merge token"]
	T --> G["Load guest cart"]
	T --> A["Load account cart"]
	G --> M["Combine lines by SKU"]
	A --> M
	M --> Q["Cap quantity at per-item limit"]
	Q --> W["Conditional write to account cart"]
	W --> X["Mark guest cart merged into account cart"]
	X --> R["Client switches to account cart_id"]

Merge rules should be boring and predictable. Lines are combined by SKU. If both carts hold the same SKU, take the larger of the two quantities, not the sum: the common case is that the shopper added the same thing on two devices, not that they want twice as many. Respect per-item limits, and move anything that cannot be merged, such as an item from another country storefront, into saved for later with a notice.

The merge must survive retries and crashes halfway through. The identity service issues a merge token at login, and the account cart records which tokens it has already applied. The guest cart is then marked as merged with a pointer to the account cart instead of being deleted, so a stale browser tab still writing to the old cart ID is redirected rather than creating a ghost cart.

Prices, stock and the handoff to checkout

The cart never promises a price. It records the price seen at add time only so it can tell the shopper "this dropped by 300 rupees" or "this went up". The authoritative price comes from pricing on every priced view, and that view returns a quote valid for about fifteen minutes.

Checkout starts from the quote. It reads the cart at the quoted version, asks pricing to confirm the quote is still honoured, and calls the reservation system to place a hold on each line with a short expiry, typically ten minutes. If any line fails, the shopper sees exactly which item changed and why before paying. Only after payment succeeds does the hold become a decrement, and only then is the cart cleared, using the same conditional write on the version checkout started from.

MomentStockPrice
Add to cartAdvisory read only, no holdRecorded for change notices
View cartCached availability, "low stock" hintsLive repricing, quote issued
Start checkoutHold placed for about 10 minutesQuote confirmed or shopper warned
Payment succeedsHold converted to a saleCharged amount equals confirmed quote
Why not hold stock on add?

Most carts are never bought. Holding stock for every add would lock up popular items in carts that are abandoned, and you would need a sweeper to release them. Holding only during checkout keeps holds short-lived and few. Flash sales are the exception: there you may hold for a few minutes at add time and accept the operational cost.

06

How to handle the interview

07

How to handle the interview

1

Say what the cart does not own. Prices, stock and orders belong to other services. Drawing that boundary early keeps the design small and shows you think in terms of ownership.

2

Decide on stock holds explicitly. Most interviewers want to hear "no hold on add, a short hold at checkout" and why, with flash sales as the exception.

3

Use versions for concurrency. Conditional writes on a version number, plus intent-style operations that can be replayed, are simpler than locks and fit a key-value store.

4

Walk through the guest merge. Max not sum, per-item limits, a merge token for retries, and a tombstone on the guest cart. Each rule prevents a specific bug.

5

Bring up idempotency before they do. Double taps and retried requests on mobile are the most common cart bug in production.

6

Connect the quote to the charge. Explain how the price at review is pinned so the shopper is never charged something they did not see.

7

Keep events off the hot path. Abandoned-cart emails and analytics read a stream. A slow email service must never slow down add to cart.

07

Mistakes to avoid

08

Mistakes to avoid

⚠️
Keeping the cart only in a cache

An in-memory store with no durable backing loses every cart on a bad restart, usually on the busiest day of the year.

Use a durable store as the source of truth. Add a cache only if measurements show you need one.
⚠️
Storing the price as a fact in the cart

A price frozen when the item was added drifts from the catalogue, and you end up either overcharging or eating losses.

Record the added price for notices only and reprice on every view and at checkout.
⚠️
Reserving inventory on every add

Abandoned carts lock up stock and a sweeper has to chase millions of expiring holds.

Hold only during checkout, with a short expiry.
⚠️
Summing quantities on merge

A shopper who added one item on two devices suddenly has two in the cart and is annoyed at checkout.

Take the larger quantity and show a notice when lines were combined.
⚠️
Last write wins with no version check

Concurrent edits from two tabs silently undo each other.

Use conditional writes and replay the losing operation on fresh state.

08

Decisions and what they cost

09

Decisions and what they cost

Chosen
One document per cart in a key-value store

Single-key reads and writes, one version number for the whole cart, and natural TTL for expiry. The record is small, so rewriting it on every change is cheap.

Fits the access pattern
Alternative
Relational table with a row per line

Gives SQL for analytics and foreign keys to the catalogue. Every view becomes a multi-row read and atomic multi-line changes need transactions, which is harder to shard.

Fine for one region and modest traffic
Chosen
Optimistic concurrency with replayable operations

No locks, no lock timeouts. Conflicts are rare and resolved by rereading and reapplying the intent. Works the same across devices and regions with a single writer region.

Simple and correct
Rejected
Per-cart lock in Redis

Serialises writes, but a crashed holder blocks the cart until the lock expires, and you have added a second system to the write path that can fail independently.

More moving parts
Chosen
Reprice on read, pin at checkout

The shopper always sees current prices and promotions, and checkout charges exactly the confirmed quote. The cost is a pricing call on every cart view.

No surprise charges
Alternative
Cache priced carts for a few minutes

Cuts pricing load during sales, but prices shown can lag a promotion change. Acceptable as a fallback when pricing is overloaded, not as the default.

Use as degradation mode
Chosen
Home-region writes for each cart

Each cart is written in one region, chosen by the user's country, and replicated elsewhere for reads and failover. Version checks stay meaningful.

Clear ownership
Alternative
Multi-region active-active with CRDT carts

An add-wins set per SKU can merge edits from two regions without coordination. Removals and quantity changes get tricky, and the extra complexity rarely pays off for carts.

Overkill for most stores

09

Failure modes

10

Failure modes

🏷️
Pricing service is down during a sale

The promotions engine falls over under sale traffic. Every cart view waits on it, cart pages time out, and shoppers think the site is broken even though their carts are fine.

Set a tight timeout on the pricing call, return the cart with last known prices and a "prices are being updated" banner, and block only the final checkout step until a confirmed quote is available.
👆
A double tap creates two orders

A shopper on a slow train taps "Place order" twice. Both requests start checkout from the same cart version, and without protection both charge the card.

Checkout takes an idempotency key derived from the cart ID and version. The second request finds the first in progress and returns its result. Clearing the cart uses a conditional write on that same version.
📉
Stock runs out between cart and checkout

A popular item sells out while ten thousand people have it in their carts. They all reach checkout at once and the reservation system rejects most of them.

Show stock warnings on the cart page from the cached inventory view, fail the reservation per line rather than for the whole order, and offer to move the missing item to saved for later so the rest of the order can go through.
🔀
A merge runs twice

The login response is lost and the client retries the merge. Without protection, the guest lines are applied again, or the second run finds the guest cart already empty and wipes lines in the account cart.

Merge tokens recorded on the account cart make the second run a no-op. The guest cart is tombstoned with a pointer, never emptied in place.
🤖
A bot fills carts with stock

Scalper bots add thousands of units of a limited item to hundreds of carts, hoping holds will lock it up.

Because adds do not hold stock, the bots gain little. Enforce per-item and per-cart quantity limits, rate-limit adds per account and device, and reserve only at checkout, where stronger bot checks run.
🌩️
The cart store loses a region

The primary region for Indian shoppers has an outage. Carts there cannot be written.

Promote the replica in a second region. Recent writes may be a few seconds behind, so clients resend their last operation with its idempotency key, which is applied once and ignored if it already made it across.

10

Growing the design in stages

12

Growing the design in stages

Rule of thumb

Start with the cart as a table in the main database. Pull it out into its own service when either its traffic or its release schedule starts hurting the rest of the store.

Stage 1: a small store
Cart rows in the main database

A cart_lines table next to products and orders, a session cookie for guests and a simple merge on login. One Postgres handles it, and that is the right call for a new store.

Stage 2: steady growth
A separate cart service

The cart moves behind its own API with a document per cart, versioned writes and idempotency keys. Pricing becomes a service call instead of a join.

Stage 3: big sale days
Partitioned store and graceful degradation

The cart store moves to a partitioned key-value database. Cart views fall back to cached prices when pricing is slow, and the cookie badge takes the most common read off the backend.

Stage 4: checkout under pressure
Quotes, short holds and an event stream

Checkout starts from a pinned quote and places ten-minute holds. Cart events go to a stream that drives reminders and analytics, so the cart stops calling other systems synchronously.

Stage 5: many countries
Regional home for each cart

Carts are written in the region that serves the shopper and replicated elsewhere for failover. Cross-storefront items are no longer merged blindly, because currency and tax rules differ.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.