A shopping cart sounds like a toy problem. “Store a list of item IDs and quantities for a user, let them add and remove, show it back.” You could do it with a row per user and a JSON blob and be done in an afternoon. Then the interviewer starts adding constraints and every easy answer falls over. The cart has to follow the user from their phone to their laptop to the Alexa in the kitchen, and show the same items on all three within a second. It has to survive the user closing the tab and coming back a week later. It has to stay writable during a Prime Day sale when a hundred million people are hammering “add to cart” on the same few thousand doorbusters at the same instant. And - the hard one - the cart cannot lie about inventory: if the warehouse has 100 units of a limited item, you must not let 500 people put it in their cart, walk to checkout, and each expect it to be there.

That last constraint is what turns this from a CRUD exercise into a distributed-systems problem. A cart is not just a private scratchpad; for scarce items it is a claim on shared, finite inventory. The moment two carts contend for the same last unit, you need a story for concurrency, reservation, expiry, and reconciliation. And you need it to hold up when the write rate goes up 50x for six hours twice a year.

Let me do it properly.

Functional Requirements (FR)

In scope:

  • Add item to cart. addItem(userId, productId, qty). Adding the same product again increments quantity, it does not create a duplicate line.
  • Update quantity / remove item. updateQty(userId, productId, qty), remove when qty hits 0.
  • View cart. Return the full cart - line items, quantities, current prices, computed subtotal - fast, on any device.
  • Cross-device consistency. The same logged-in user sees the same cart on web, mobile app, and any other client, converging within ~1 second of a change.
  • Persistence across sessions. The cart survives logout, app kill, and days of inactivity. It is durable server-side state, not a browser cookie.
  • Guest carts and merge on login. Anonymous users build a cart tied to a device token; on login it merges into their account cart (union the items).
  • Inventory-aware add for scarce items. For limited-stock / flash-sale items, adding to cart optionally reserves stock so the item is actually available at checkout and the system never oversells.

Explicitly out of scope (state this to control the interview):

  • Checkout, payment, and order placement. The cart hands off to the order/payment service at checkout; we design the cart and the inventory-reservation contract, not the payment flow.
  • Pricing, promotions, tax, and coupon math. We store product references and read current price at view time; the pricing engine is a separate service.
  • Product catalog and search. The cart references productId; the catalog owns product data.
  • Recommendations (“customers also bought”). A separate system.
  • Full warehouse inventory management. We design the reservation slice the cart needs (soft holds, atomic decrement, oversell prevention), not the whole supply-chain inventory system.

The single decision that shapes the design: a cart is durable, cross-device, per-user state that occasionally needs to place a soft claim on shared inventory. The first three quarters of that (“durable, cross-device, per-user”) is a replicated key-value problem. The last quarter (“soft claim on shared inventory without overselling”) is a concurrency-and-consistency problem, and it is where interviews are won or lost.

Non-Functional Requirements (NFR)

  • Scale: 200M active users, ~300M+ devices. Steady-state on the order of 50-100K cart writes/sec; during a major sale, cart writes spike to 1-2M/sec concentrated on a small set of hot products.
  • Latency: cart read (view cart) p99 < 100 ms - it sits on the path to checkout and every page render. Add-to-cart write p99 < 150 ms. Cross-device propagation < 1 s.
  • Availability: the cart must be highly available for writes - 99.99%+. A user who cannot add to cart does not buy. This biases us toward “always writable, reconcile later” for the cart body itself. (Inventory reservation is the deliberate exception: for scarce items we trade a little availability for correctness, because overselling is worse than a rejected add.)
  • Consistency: cart contents are read-your-writes per user (you must see your own add immediately) and eventually consistent across devices within a second. Inventory reservation for scarce items must be strongly consistent / linearizable per product - the decrement of available stock cannot race.
  • Durability: an acknowledged cart write must survive node loss. A cart representing hours of shopping intent is expensive to lose. Replicate across failure domains, persist to disk.

The tension to name up front: the cart body wants AP (availability, partition tolerance, reconcile conflicts) and the scarce-item reservation wants CP (never oversell, reject under partition). We will build the cart as an AP store and layer a small CP reservation service beside it, rather than forcing one consistency model on both.

Back-of-the-Envelope Estimation (BoE)

Let me ground it in numbers.

Users and traffic:

Total users:               200,000,000  (200M)
Daily active users (DAU):  ~100M
Peak concurrent shoppers:  ~10M

Cart writes per active user per day: ~10 (adds, qty changes, removes)
  100M DAU * 10 writes = 1,000,000,000 cart writes/day
  1B / 86,400 s = ~11,600 writes/sec average
  Peak factor ~5x (evening) -> ~58,000 writes/sec steady peak.

Reads dominate (every page view reads the cart badge / mini-cart):

Cart reads per user per day: ~50 (badge on every page, mini-cart, checkout)
  100M DAU * 50 = 5,000,000,000 reads/day
  5B / 86,400 = ~58,000 reads/sec average
  Peak ~5x -> ~290,000 reads/sec.

Read:write ratio ~5:1. Reads must be cache-served.

Sale-time spike (the design driver):

Prime Day / Big Billion style event:
  Add-to-cart on doorbusters: 1,000,000 - 2,000,000 writes/sec
  Concentrated on ~1,000-10,000 hot products.
  A single doorbuster SKU can take 100,000+ add attempts/sec.
This is a 30-50x jump over steady peak, on a TINY key space.
-> hot-key problem + inventory contention on those SKUs.

Storage:

Avg cart: ~5 line items, each ~50 bytes (productId, qty, addedAt, price snapshot)
  Cart body ~ 300-500 bytes + overhead -> call it ~1 KB per cart.
Active carts (users who shopped in last 90 days): ~200M
  200M * 1 KB = 200 GB of live cart data.
Replicated 3x -> 600 GB. This fits comfortably; the cart corpus is SMALL.

The storage number is the headline: the entire live cart dataset is a few hundred GB. This is not a storage problem. It means the whole hot working set fits in memory across a modest cluster - carts belong in an in-memory-first, durably-backed key-value store, not a giant sharded SQL cluster. The difficulty is not volume; it is (a) the read/write QPS with sub-second cross-device sync, and (b) the sale-time contention on a handful of scarce SKUs. Size the design for hot keys and reservation correctness, not for terabytes.

Cache sizing:

Keep all active carts hot: 200M * 1 KB = 200 GB working set.
With a KV store keeping data in RAM (or a Redis tier in front),
  200 GB spread over, say, 20 nodes = 10 GB/node. Trivial.
So: cache the entire live cart set. Reads never touch disk on the hot path.

Bandwidth: negligible. 290K reads/sec * 1 KB = ~290 MB/sec aggregate, spread over the fleet. The bytes are not the problem; the coordination on hot SKUs is.

High-Level Design (HLD)

The system splits into three planes: a Cart Service backed by a replicated in-memory KV store (the cart body - AP, always writable), an Inventory / Reservation Service backed by a strongly-consistent store (the scarce-item claim - CP, never oversell), and a sync/notification path that pushes cart changes to a user’s other devices. Clients talk to the Cart Service through an API gateway; the Cart Service calls the Reservation Service only when an item is flagged scarce.

        ┌──────────┐   ┌──────────┐   ┌──────────┐
        │  Web     │   │  Mobile  │   │  Alexa   │   (same user, N devices)
        └────┬─────┘   └────┬─────┘   └────┬─────┘
             └──────────────┼──────────────┘
                            │  add / remove / view (userId + auth)
                   ┌────────▼─────────┐
                   │   API Gateway    │  auth, rate-limit, route
                   └────────┬─────────┘
                            │
        ┌───────────────────▼────────────────────┐
        │            Cart Service (stateless)      │
        │  - validates, reads/writes cart          │
        │  - for scarce SKUs, calls Reservation    │
        │  - publishes change events for sync      │
        └───┬───────────────┬──────────────┬───────┘
            │               │              │
   ┌────────▼───────┐  ┌────▼─────────┐  ┌─▼───────────────┐
   │  Cart Store    │  │ Reservation  │  │  Change Stream  │
   │ (replicated KV │  │  Service +   │  │  (Kafka topic,  │
   │  in-memory,    │  │  CP store    │  │  keyed by user) │
   │  Dynamo-style, │  │ (per-SKU     │  └──────┬──────────┘
   │  N=3, LWW/CRDT)│  │  atomic dec) │         │ fan-out
   └────────────────┘  └──────────────┘  ┌──────▼──────────┐
     key = userId       key = productId   │ Push / WebSocket │
     value = cart doc    strong per-key   │  gateway         │
                                          └──────┬──────────┘
                                                 │ "cart changed"
                                          ┌──────▼──────────┐
                                          │ user's OTHER     │
                                          │ devices refetch  │
                                          └─────────────────┘

Request flow - add to cart (write path):

  1. Client sends POST /cart/items {productId, qty} with the user’s auth token (or a device token for a guest). The gateway authenticates and routes to any Cart Service instance (stateless).
  2. Cart Service checks whether the product is scarce (flagged in the catalog / a hot-SKU set). Common case, not scarce: skip to step 4.
  3. Scarce path: Cart Service calls Reservation Service reserve(productId, qty, holderId). That service does a strongly-consistent conditional decrement of available stock. If it succeeds it returns a reservation with a TTL; if stock is exhausted it returns OUT_OF_STOCK and the add fails cleanly (no oversell).
  4. Cart Service reads the user’s cart from the Cart Store (key = userId), merges the item (increment qty if present, else add line), and writes it back with W=2 quorum. The write carries version metadata so concurrent writes from two devices reconcile.
  5. Cart Service publishes a cart.changed {userId, version} event to the change stream (keyed by userId) and returns the updated cart to the caller.
  6. The push gateway consumes the event and notifies the user’s other connected devices (“your cart changed”), which refetch. Read-your-writes on the calling device is already satisfied by step 5’s response.

Request flow - view cart (read path):

  1. Client sends GET /cart. Gateway routes to any Cart Service instance.
  2. Cart Service reads key = userId from the Cart Store. With the whole live set in memory this is a sub-millisecond lookup, R=1 for speed (eventually consistent) or R=2 for read-your-writes strength.
  3. Cart Service hydrates line items with current price and availability from the catalog/pricing service (the cart stores a price snapshot for display but re-checks at view/checkout so stale prices do not surprise the user), computes subtotal, returns the assembled cart.

The key architectural decision: two stores, two consistency models, joined at the Cart Service. The cart body lives in an always-writable AP KV store keyed by userId (fast, durable, cross-device). The scarce-item claim lives in a CP store keyed by productId (strongly consistent, never oversells). Forcing both into one model is the classic mistake - a fully-CP cart rejects adds during partitions (users cannot shop), and a fully-AP inventory oversells (users get to checkout and the item is gone). Splitting them lets each be correct.

Component Deep Dive

Four hard parts: (1) the cart store and how add/remove survives concurrent multi-device writes, (2) cross-device sync within a second, (3) inventory reservation that never oversells under sale-time contention, and (4) surviving the sale-time write spike on hot SKUs. Each walked from naive to scalable.

1. The Cart Store: Row-per-item SQL -> Cart-doc KV -> Conflict-free merge

Approach A: A SQL table, one row per cart line (the naive instinct)

cart_items(user_id, product_id, qty, added_at,
           PRIMARY KEY (user_id, product_id))

Add is an upsert, remove is a delete, view is SELECT ... WHERE user_id = ?. Clean and obvious.

Where it breaks. Two problems at 200M users and sale spikes. First, the hot-partition / write-amplification problem: a single SQL primary and its replicas cannot absorb 1-2M cart writes/sec during a sale, and sharding a relational store by user_id gives you the availability of your least-available shard - a shard down means a slice of users cannot add to cart. Second, and worse, it is the wrong consistency model for a cart. A cart wants to be always writable; a relational primary that rejects writes during a partition or failover means users cannot shop exactly when it matters most. The row-per-item model also makes cross-device merges awkward: if the user’s phone (offline briefly) and laptop both edited the cart, the SQL store just takes the last write and silently loses the other device’s change.

Approach B: One cart document per user in a replicated KV store

Model the whole cart as a single value keyed by userId, stored in a Dynamo-style, in-memory-first, replicated KV store (N=3, tunable quorum). This is exactly the workload Amazon’s original Dynamo paper was built for - the shopping cart is the canonical Dynamo use case.

key:   userId
value: {
  version: <vector clock or version vector>,
  items: [ {productId, qty, addedAt, priceSnapshot}, ... ],
  updatedAt: <ts>
}

Add / remove = read-modify-write of the one document. Because the entire live set fits in RAM, reads are sub-millisecond and the store stays always writable via sloppy quorum and hinted handoff (a write goes to the next healthy node if a home replica is down, and is handed back on recovery). This fixes availability and puts all of one user’s cart in one place - but read-modify-write across devices reintroduces the lost-update problem: two coordinators can accept concurrent writes to the same cart.

Approach C: Conflict-free merge (the fix for concurrent device edits)

The Dynamo shopping-cart insight: do not let last-write-wins silently drop a concurrent add. If the phone adds “milk” and the laptop adds “eggs” at the same instant, LWW keeps one and throws the other away - the user watches an item vanish. Instead, detect true concurrency and merge.

Two workable mechanisms:

  • Version vectors + client/service merge (the Dynamo approach). Each write carries a version vector. On read, if two replicas hold concurrent (neither dominates) versions, the store returns both siblings and the Cart Service merges them by unioning the line items and taking the max quantity per product (or summing adds - a design choice; max-of-observed is the safe default to avoid double-counting). It writes back a version that dominates both, collapsing the siblings.

  • A cart CRDT (cleaner for high concurrency). Model the cart as an observed-remove map (OR-Map) from productId to a quantity register. Adds and removes commute and merge deterministically with no coordination, so any two replicas that have seen the same set of operations converge to the same cart regardless of order. This removes the “return siblings to the app” step - merge is intrinsic to the data type.

Concurrent edit, two devices:
  phone:  add(milk,1)   -> cart {milk:1}          version {phone:1}
  laptop: add(eggs,1)   -> cart {eggs:1}          version {laptop:1}
Replicas hold concurrent versions (neither dominates).
Merge (union items, max qty per product):
  -> cart {milk:1, eggs:1}  version {phone:1, laptop:1}
BOTH items survive. LWW would have kept only one.

The one subtlety is remove vs concurrent add: if the user removes “milk” on the laptop while the phone re-adds “milk”, a plain merge resurrects it. The OR-Map handles this with tombstones tagged by the add’s unique id - a remove only cancels the specific add it observed, so a new add after the remove survives, but the removed one stays gone. This is the same tombstone discipline a Dynamo store uses for deletes, applied per line item.

Decision: cart body in an AP, in-memory-first KV store keyed by userId, N=3, with either version-vector siblings merged by the Cart Service or an OR-Map cart CRDT. Default to LWW only for the trivial single-device case; use merge for cross-device correctness. This is a NoSQL choice, and the reason is explicit: the cart wants availability over strong consistency, schemaless per-user documents, and conflict merge - none of which a relational store gives you cheaply.

2. Cross-Device Sync Within a Second

The user adds an item on their phone; their open laptop tab should reflect it in ~1s. Three ways, naive to good.

Approach A: Client polling

Every device polls GET /cart every few seconds. Where it breaks: at 300M devices, polling every 5s is 60M reads/sec of mostly-unchanged data - a self-inflicted DDoS on the cart store, and still up to 5s stale. The vast majority of polls return “nothing changed.” Wasteful and slow.

Approach B: Long-lived push via WebSocket / mobile push

Each active device holds a WebSocket to a push gateway (or is reachable via mobile push tokens). When the cart changes, the Cart Service publishes cart.changed {userId} to a change stream (Kafka topic keyed by userId); a fan-out consumer looks up the user’s other connected devices and pushes them a lightweight “cart changed, version=V” nudge. Devices that see a newer version than they hold refetch the cart.

add on phone -> Cart Service writes cart, publishes cart.changed{user=U, v=7}
  -> Kafka (partition = hash(U))
  -> fan-out consumer: U has laptop + tablet connected
  -> push "cart changed v7" to laptop & tablet
  -> laptop holds v6 < v7 -> GET /cart -> shows updated cart

Push the notification, not the full cart - the payload is a version number, so a flood of changes collapses to “you are behind, refetch once.” This bounds fan-out cost and naturally debounces. Propagation is well under a second, and idle devices cost only an idle connection.

Approach C: Refinements that matter at scale

  • Key the stream by userId so all of one user’s changes are ordered on one partition - a device never sees version 7 before version 6.
  • Version gating. The device includes its held version on refetch; the service can short-circuit if the device is already current (rare, but cheap).
  • Fallback to poll-on-focus. Devices without a live connection (backgrounded app) refetch on foreground. Push is best-effort; the durable source of truth is always the cart store, so a missed push just means “refresh on next view,” never lost data.

The rule: the cart store is the source of truth; sync is a fast-path nudge, never the system of record. If every push were lost, correctness would be unaffected - devices would just converge on next view instead of within a second.

3. Inventory Reservation: Never Oversell (the hard part)

For scarce items (flash-sale doorbusters, limited stock), “in your cart” must mean “actually held for you.” This is where the AP cart is not enough and a CP reservation is required.

Approach A: No reservation - check stock at checkout only

Cart just stores items; inventory is checked when the user hits “Place Order.” Where it breaks: 500 people add the last 100 units to their carts, all walk to checkout, 400 of them get “sorry, out of stock” at the final step. Terrible experience, and if the checkout decrement itself is not atomic, you oversell and have to cancel real orders. For scarce items, checking only at the end is a broken promise.

Approach B: Decrement a stock counter on add (naive reservation)

On add-to-cart, decrement available_stock for the product. Where it breaks: two failures. (1) The race: read stock; if > 0, write stock-1 is not atomic - under a sale, thousands of concurrent adds all read “5 left,” all decrement, and you go negative (oversell). (2) Abandoned carts leak stock: most carts never check out, so decrementing on add permanently locks inventory that no one buys - the item shows “sold out” while 90% of the “reservations” are dead carts. Naive decrement both races and leaks.

Approach C: Atomic conditional decrement + TTL soft holds (the fix)

Two mechanisms together: make the decrement atomic, and make reservations expire.

Atomic decrement. Route all reservations for a product to a single authority for that key (the CP reservation store, strongly consistent per productId) and do the decrement as one atomic conditional operation - not read-then-write:

reserve(productId, qty, holderId):
  atomic:
    if available >= qty:
       available -= qty
       holds[holderId] = {qty, expiresAt = now + TTL}
       return RESERVED(expiresAt)
    else:
       return OUT_OF_STOCK

Because it is a single atomic compare-and-decrement (a Redis Lua script / a DB conditional update / a per-key serialized actor), concurrent reservers are serialized on that key and available can never go below zero. No oversell, ever, no matter the concurrency. This is deliberately CP: the per-product decrement is linearizable, which is why we keep it out of the AP cart store.

TTL soft holds solve the abandoned-cart leak. A reservation is not permanent - it holds stock for a TTL (say 15 minutes). If the user checks out, the hold converts to a committed decrement (the sale). If the TTL expires, a reaper releases the qty back to available:

Hold lifecycle:
  reserve -> HELD (expiresAt = now+15m)   [stock decremented]
  checkout before expiry -> COMMITTED     [permanent, hold removed]
  TTL expires, no checkout -> RELEASED     [stock += qty, available again]

Reaper: sweep expired holds, return qty. Or lazy: on next reserve,
  if available==0, first reclaim any expired holds for this product.

This is the airline-seat / event-ticket pattern applied to carts. Stock is only ever soft-held, so an abandoned cart automatically frees its units in 15 minutes instead of locking them forever, while a genuine buyer’s hold guarantees the item is there at checkout.

Extending a hold and idempotency. Re-adding or bumping quantity extends the hold’s TTL. Every reserve carries an idempotency key (userId+productId+requestId) so a client retry does not double-decrement - the reservation store dedups on it. Releasing must also be idempotent (releasing an already-released hold is a no-op) so the reaper and an explicit remove cannot double-credit stock.

Only scarce items pay this cost. The overwhelming majority of SKUs have effectively unlimited stock; adding them to cart takes the fast AP path with no reservation call at all. The CP reservation machinery kicks in only for items flagged scarce/hot, so the expensive coordination is confined to the few thousand SKUs that actually contend.

4. Surviving the Sale-Time Spike on Hot SKUs

During a sale, 100K+ add attempts/sec can hit one doorbuster SKU. That single key is now the bottleneck - even a perfect atomic decrement serializes on it, and one key lives on one shard.

Naive: one counter per product

All reserves for the hot SKU serialize on its single counter. Where it breaks: a single key can serve maybe tens of thousands of atomic ops/sec, not hundreds of thousands. The hot SKU’s shard melts, and it becomes a single point of failure for that product’s sale.

Fix: shard the stock counter + shed load at the edge

Split the counter (write-sharding a hot key). Represent the SKU’s stock as K sub-counters (stock:SKU:0 .. stock:SKU:K-1), each holding stock/K units, spread across K shards. A reserver hashes to one bucket and decrements it atomically. Aggregate available = sum of buckets. This turns 100K ops/sec on one key into 100K/K ops/sec on each of K keys - horizontal headroom for the hottest SKU.

SKU with 10,000 units, K=50 buckets:
  stock:SKU:0..49, each seeded with 200 units.
  reserve -> pick bucket = hash(holderId) % 50 -> atomic dec that bucket.
  Bucket empty? -> try a couple of neighbors, then OUT_OF_STOCK.
50x the per-key throughput; total across buckets can never exceed 10,000.

The trade-off: a bucket can hit zero while others still have units, so a reserver may see a false “out” and retry another bucket. A little rebalancing (a background task levels buckets, or reservers probe N buckets before giving up) keeps utilization high without ever exceeding true stock.

Shed load before it reaches inventory. Two edge defenses so the reservation store only sees traffic that can succeed:

  • Admission control / virtual waiting room. For a known doorbuster, gate entry - a token queue admits users at the rate stock can actually serve. If 10K units are for sale, there is no point letting 500K concurrent reserves stampede the counter; admit in waves.
  • Fast-fail on sold-out. Once aggregate available hits 0, flip a cached sold_out:SKU flag (short TTL) that the Cart Service and CDN edge read before calling the reservation store. Millions of “add” attempts after sell-out get an instant “sold out” from cache instead of hammering the CP store.

Keep the cart body on the fast path. Even during the spike, the cart write itself (append the item to the user’s cart doc) stays on the always-writable AP store. Only the scarce-SKU reservation touches the CP path. So the 1-2M writes/sec of cart mutations are absorbed by the horizontally-scaled cart KV store, and only the reservation decrements funnel through the sharded CP counters. Separating these two is what lets the system stay writable while still not overselling.

API Design & Data Schema

Cart API (data plane)

GET  /v1/cart
     headers: Authorization (userId) or X-Device-Token (guest)
     resp: 200 {
       version, items: [{productId, qty, price, title, inStock}],
       subtotal, currency
     }

POST /v1/cart/items
     body: { productId, qty }            # qty>0; adds or increments
     resp: 200 { version, items:[...] }
           409 { error: "OUT_OF_STOCK", available }   # scarce item exhausted

PATCH /v1/cart/items/{productId}
     body: { qty }                        # set absolute qty; qty=0 removes
     resp: 200 { version, items:[...] }

DELETE /v1/cart/items/{productId}
     resp: 200 { version, items:[...] }

POST /v1/cart/merge
     body: { guestDeviceToken }           # merge guest cart into user cart on login
     resp: 200 { version, items:[...] }    # union items, max qty per product

Reservation API (internal, called by Cart Service for scarce SKUs)

POST /v1/reservations
     body: { productId, qty, holderId, idempotencyKey }
     resp: 201 { reservationId, expiresAt }          # HELD, stock decremented
           409 { error: "OUT_OF_STOCK", available }

POST /v1/reservations/{id}/extend
     resp: 200 { expiresAt }              # bump TTL on re-add / quantity change

POST /v1/reservations/{id}/commit
     resp: 200                            # checkout: hold -> permanent sale

DELETE /v1/reservations/{id}
     resp: 204                            # release (remove item / cancel); idempotent

Notes: POST /cart/items returning 409 OUT_OF_STOCK is the honest failure - the item was never added because it could not be reserved, so the cart never lies. idempotencyKey makes reserve safe to retry. Commit/extend/release are the hold state machine.

Data schema: cart body (NoSQL KV)

One document per user, in a Dynamo-style KV store. NoSQL, not SQL, because: access is always by the single key userId, the value is a schemaless per-user document, we want always-writable availability with conflict merge, and the whole live set fits in memory. A relational store buys us joins and transactions we do not need and costs us the availability we do.

Table: carts        (partition key = userId)
{
  userId        STRING   (PK, shard key)
  version       VVECTOR  # version vector for concurrent-write merge
  items         LIST< {
                   productId   STRING,
                   qty         INT,
                   addedAt     INT64,
                   priceSnap   INT,        # display only; re-checked at view
                   reservationId STRING?   # set only for scarce items
                 } >
  updatedAt     INT64
  ttl           INT64    # guest carts expire; user carts long-lived
}
Storage: in-memory-first LSM KV, N=3 replicas across racks/DCs,
         W=2/R=2 default (read-your-writes), sloppy quorum for availability.
Index: PK only. No secondary indexes needed - always keyed by userId.

Data schema: reservation / inventory (CP store)

Table: inventory    (key = productId)          # strongly consistent per key
{
  productId     STRING  (PK)
  totalStock    INT
  buckets       LIST<INT>   # K sub-counters for hot SKUs (write-sharding)
  available()   = sum(buckets)                  # derived
}

Table: holds        (key = reservationId)
{
  reservationId STRING (PK)
  productId     STRING (indexed, for reaper sweep by product)
  holderId      STRING          # userId or device token
  qty           INT
  state         ENUM(HELD, COMMITTED, RELEASED)
  expiresAt     INT64 (indexed, for TTL reaper)
  idempotencyKey STRING (unique)  # dedup retries
}
Store: strongly-consistent KV / DB with atomic conditional updates
       (Redis+Lua, or a CP store like a Raft-backed KV / a SQL row with
        SELECT ... FOR UPDATE). Per-key linearizable decrement.

The split is the whole point: carts is AP and huge-QPS-friendly; inventory/holds is CP and touched only for scarce SKUs.

Bottlenecks & Scaling

Where it breaks first, in order, and the fix:

1. Hot SKU reservation counter (breaks first during a sale). 100K+ reserves/sec on one product key serialize on one shard and melt it. Fix: write-shard the stock counter into K sub-counters across shards (100K/K per key), fast-fail via a cached sold_out flag once aggregate available hits 0, and gate entry with an admission/waiting-room so only servable traffic reaches the CP store. This is the single most important scaling move for sale time.

2. Cart-store hot shard for a celebrity user or hot partition. Uneven load if the shard key clusters. Fix: shard the cart store by userId (high cardinality, even spread) using consistent hashing with virtual nodes, so 200M users distribute evenly and adding nodes moves only ~1/N of data. No single user is hot enough to matter; the hot keys are products, which live in the separate inventory store.

3. Read QPS (290K/sec) on the cart badge. Every page render reads the cart. Fix: the cart store is in-memory-first so reads never hit disk; front it with a read cache (the cart doc is tiny), serve badge/mini-cart reads at R=1 (eventually consistent, fine for a badge) and use R=2 only where read-your-writes matters (right after an add, at checkout).

4. Cross-device sync fan-out. Pushing every change to every device does not scale by polling. Fix: push a version nudge over WebSocket/mobile-push via a Kafka stream keyed by userId, not the full cart; devices refetch only when behind. Ordering per user comes from the userId partition. Push is best-effort; the store is the source of truth.

5. Abandoned carts leaking scarce stock. Most carts never check out; permanent decrements lock inventory. Fix: TTL soft holds - reservations expire in ~15 min and a reaper (or lazy reclaim on next reserve) returns the stock. Only committed checkouts decrement permanently.

6. Oversell under concurrency / partition. Read-then-write decrements race; AP replication would let two partitions both sell the last unit. Fix: atomic conditional decrement on a per-product CP authority (linearizable per key), idempotent reserve/release. Deliberately choose CP for inventory: under partition, reject the reserve rather than oversell. The AP cart body keeps users shopping everything else.

7. Guest-to-user cart merge conflicts. Login must not drop items from either cart. Fix: union merge (max qty per product, keep both carts’ items), the same conflict-free merge used for cross-device writes; guest carts carry a TTL so they self-clean.

8. Stale prices in the cart. A price snapshot taken at add time can be wrong days later. Fix: store priceSnap for display continuity but re-read current price and stock at view and checkout from the pricing/catalog service; the cart references products, it does not own prices. Show the user any change before they pay.

9. Reservation store single point of failure. If the CP inventory store is down, scarce-item adds fail. Fix: replicate the CP store with consensus (Raft/Paxos) across nodes so it tolerates a minority failure while staying linearizable; keep it small (only scarce SKUs) so consensus overhead is bounded. Non-scarce adds never touch it, so its outage degrades only flash-sale items, not all shopping.

Wrap-Up

The trade-offs that define this design:

  • Two stores, two consistency models, over one uniform store. The cart body is AP (always writable, conflict-merged, keyed by userId); scarce-item inventory is CP (linearizable atomic decrement, keyed by productId). Forcing both into one model either rejects cart writes during partitions or oversells - splitting them lets each be correct where it must be.
  • Conflict-free merge over last-write-wins for the cart. We accept version-vector siblings (or a cart CRDT / OR-Map) and union concurrent edits, because LWW silently drops an item a second device just added. The cost is merge logic and per-line tombstones for remove-vs-readd; the payoff is no vanishing items across devices.
  • Soft TTL holds over permanent decrement for scarce stock. Reservations expire so abandoned carts return their units automatically, while genuine buyers are guaranteed the item at checkout. We trade a background reaper and idempotent release for not locking inventory behind dead carts and not overselling.
  • Write-sharded hot counters and edge load-shedding over a single stock key. A doorbuster’s counter is split K ways and protected by a cached sold-out flag and an admission queue, because one key cannot absorb 100K reserves/sec. The cost is occasional false “out” on an empty bucket and rebalancing; the payoff is the hottest SKU survives the spike.
  • Push-nudge sync over polling, with the store as source of truth. Devices get a version nudge and refetch, never the full cart on a timer. Push is best-effort; a lost nudge just means “converge on next view,” never lost data.

One-line summary: an always-writable, in-memory-first KV store keyed by userId holds the cart and merges concurrent multi-device edits conflict-free, a separate strongly-consistent per-product reservation service with atomic decrements and TTL soft holds guarantees scarce items are never oversold, hot SKUs survive sale spikes via write-sharded counters and edge load-shedding, and a userId-keyed push stream keeps every device in sync within a second - trading a single uniform consistency model for a cart that stays available under load while inventory stays honest.