</>longpham.tech
All posts
·8 min read

Everyone Adds to Cart, Few Check Out

A flash sale is the same contended inventory as ticketing, but the winning design is the opposite: oversubscribe the cart and decrement only at checkout. A runnable lab measuring the funnel, a payment-TTL stock reclaim, and a per-user anti-scalper cap.

#concurrency#e-commerce#architecture#distributed-systems#security

A flash sale is the same contended, finite inventory as selling tickets — but the correct design is the opposite of a seat map. A ticket seat is reserved the moment it's in your cart; a flash-sale item is not, and that's on purpose. You've felt it: at 10:00 sharp everyone can add the item to their cart, but most people hit "sold out" at checkout. That's not a bug — it's the right architecture, and here's why, measured.

Runnable companion: ecommerce-flash-sale on GitHub. make funnel && make timeout && make limit reproduces every number below (pure Python, no deps). Sibling: high-demand-ticketing.

Decrement at checkout, not in the cart

The key decision is when stock leaves inventory. Reserve-at-cart (ticketing style) decrements on add-to-cart; deduct-at-checkout (flash-sale style) touches nothing until the order is placed. With 400 shoppers, 100 units, as cart abandonment rises:

abandon   strategy             add-to-cart   sold   wasted
  20%     reserve-at-cart              25%     80       20
          deduct-at-checkout          100%    100        0
  40%     reserve-at-cart              25%     60       40
          deduct-at-checkout          100%    100        0
  60%     reserve-at-cart              25%     40       60
          deduct-at-checkout          100%    100        0

reserve-at-cart locks a unit for every abandoner, so the cart "sells out" to holds — only 25% of shoppers even get an item into the cart, and at 40% abandonment it sells just 60 of 100 units. deduct-at-checkout keeps 100% of stock available to buyers who actually convert, so it sells the full 100 at every abandonment rate. "Everyone adds to cart, few check out" is the design working: it refuses to waste stock on carts that will be abandoned.

Pending orders need a payment TTL

Deducting at checkout leaves a smaller leak: a buyer places the order (stock −1, pending) then never pays. A TTL cancels the unpaid order and returns the unit to waiting demand:

non-payment    no timeout: sold    with timeout: sold
    10%             90 (90%)             100 (100%)
    30%             70 (70%)             100 (100%)
    50%             50 (50%)             100 (100%)

At 30% non-payment you sell only 70% of stock without a TTL; the timeout brings it back to ~100%. It's the seat-hold + reaper pattern from ticketing, moved to the order level.

A per-user cap stops scalpers

Flash sales attract bots — a few accounts firing many requests each. 100 units, 200 genuine buyers (one request) + 20 scalpers (50 requests each):

policy                  to scalpers   unique buyers   most by one
no limit                    83 (83%)             36           8.1
per-user limit = 1          20 (20%)            100           1.0

With no cap, stock goes to whoever sends the most requests — scalpers scoop 83% and only ~36 people are served. A one-per-account cap drops that to 20% and spreads the drop across all 100 unique buyers. But a cap on accounts is only as strong as account identity.

A cap on users isn't a cap on bots

A bot creates thousands of free fake accounts (a Sybil attack), so a per-account cap barely dents it — you need defense in depth, where each layer spends a different scarce resource the bot has to have. Scalper share of 100 units, adding one layer at a time (bot: unlimited fake accounts, 15 real cards, 85% datacenter IPs):

defense (cumulative)     to scalpers   real buyers blocked
no defense                    94%              0
+ per-user cap                94%              0
+ datacenter/ASN block        69%              0
+ payment/card cap            15%              0
+ account-age gate             0%             25

Per-user cap alone ≈ no defense (fake accounts are free). Blocking datacenter/proxy IPs cuts most bot traffic; a per-card cap pins the bot to its few real cards (the cheap decisive layer); an account-age gate stops brand-new Sybil accounts entirely — but locks out 25 real first-time buyers, so it's a trade-off. Anchor on what bots can't cheaply mass-produce (cards, phone numbers, aged accounts, non-datacenter IPs); it's an economics game — raise the attacker's cost above their profit, and cancel-and-refund suspicious orders after the fact.

What carries over from ticketing

The checkout itself still needs the mechanics proven in the ticketing lab, unchanged: an atomic decrement so a burst of orders never oversells (buying N units is one all-or-nothing update), idempotent payment on a client key, and a virtual waiting room for backpressure when the spike is large. The one real difference is risk appetite: ticketing must never oversell; e-commerce often tolerates a small deliberate oversell (backorder / refund) to lift conversion — a product decision, not a correctness one.

What I'd say in an interview

  • Reserve stock in the cart only for unique, rarely-abandoned units (a seat). For fungible goods with high abandonment, oversubscribe the cart and decrement at checkout — you sell the full stock instead of wasting it on abandoners (measured: 60 → 100 of 100 units at 40% abandonment).
  • Put a TTL on the pending order so non-payers don't strand inventory — the reaper pattern at the order level (70% → ~100% utilization).
  • A per-user cap alone is beaten by fake accounts (Sybil) — stack defense in depth (datacenter/ASN block, per-card cap, account-age gate), anchoring on resources bots can't mass-produce; each layer has a cost or a gap.
  • The checkout decrement is still atomic and payment idempotent — same invariants as ticketing; only the reservation point and the oversell tolerance change.