all_lessons / system_design / cases / C30 C30 / C44

Design a flash sale system

A flash sale is a deliberately engineered overload. A million people arrive for a thousand units, so the design's job is not to serve everyone — it is to reject 999,000 requests cheaply and fairly while overselling exactly zero.

Source: Archive Case drill Trade-off first
First principle — the overload ratio decides everything
Put a number on the problem before drawing a box. If 1,000,000 shoppers click "buy" for 1,000 units, the overload ratio is 106 / 103 = 1000 : 1. 99.9% of requests are guaranteed to fail no matter what you build. That reframes the whole design: you are not building a system that serves demand, you are building a system that destroys demand as early and as cheaply as possible, and lets only a stock-sized trickle reach the parts that do real work. Everything below is an argument about where each request dies.

0. The forcing constraint: writes are bounded by stock, not by traffic

The hinge that makes this architecture non-generic: the number of successful database writes can never exceed the stock count. 1,000 units means at most 1,000 inventory decrements and 1,000 orders — full stop. The 1M requests are a property of demand; the 1,000 writes are a property of supply. A naive design lets request volume drive write volume (every click attempts a DB transaction), so the database sees 1M conditional updates against a single row and melts. The correct design forces write volume to track supply: the scarce database only ever sees work proportional to the 1,000 units, and the other 999,000 requests are absorbed by progressively cheaper layers in front of it.

The two things that melt if you ignore the ratio
(1) The stock row. 1M concurrent read-modify-write against one counter is a hot key (foundation lesson 07) and a lost-update race (DDIA Ch. 7). (2) The waiting room. "Just queue everyone" sounds safe, but 1M concurrent connections parked on one queue structure is itself a 1M-wide hot key. If the admission layer isn't sharded, the thing you built to protect the database becomes the thing that falls over first.

1. Clarify the contract

State what the system must guarantee — and, just as important, what it explicitly need not.

What it need not do: serve everyone, be perfectly fair (we will show that's impossible under bot pressure), or keep the sale page personalized. Dropping those three relaxations is what makes the problem tractable.

2. Put numbers on the shape

Assume 1M shoppers arrive within a ~10 s window at sale start, 1,000 units, and a checkout service that can comfortably sustain ~2,000 req/s.

Overload ratio (demand : stock)
1000 : 1
Peak arrival rate (1M / 10 s)
100,000 req/s
Requests that MUST be rejected
999,000 (99.9%)
Max DB inventory writes (= stock)
1,000

The arithmetic that drives the design: peak arrival is 106 / 10 = 105 req/s, but the checkout tier can take only ~2,000 req/s. So the admission gate must shed 100{,}000 − 2{,}000 = 98{,}000 req/s at the edge, and even the 2,000 req/s it lets through is ~2× the stock per second — admission is generous on purpose, because the atomic stock counter (not the gate) is the true backstop against oversell. Note also the cost asymmetry from foundation lesson 15: an edge rejection is a CDN response or a counter check (tens of microseconds, zero downstream cost), whereas an admitted request consumes a connection, a token verification, and a conditional DB write. Rejecting cheaply is the entire game.

3. Define the surface area

Keep the API small and don't leak the queue/shard internals to the client.

API / operationWhy it exists
GET /sale/{id}Static, CDN-served sale page + a countdown. Must survive 1M hits with zero origin load.
POST /sale/{id}/enterJoin the (sharded) waiting room; returns a position/ticket. Cheap, no inventory touched.
POST /sale/{id}/checkoutRequires a valid signed admission token; attempts the atomic stock decrement + hold.
GET /sale/{id}/statusPoll queue position / "admitted" / "sold out". Served from cache, never the DB.

4. Model the data

The data model is the first architecture. Three pieces carry the invariant.

sale (static metadata)waiting-room shard counterssigned admission tokenatomic stock counterhold / reservationorder

5. Linearized design

Walk a request through the funnel; each layer is cheaper than the one behind it and exists to keep load off the next.

  1. 1. CDN pre-warm. The sale page, prices, and assets are static and pushed to the CDN before the sale. 1M page views hit the edge, ~0 hit the origin (foundation lesson 06).
  2. 2. Edge shed. The gateway rate-limits per IP/account and drops obvious floods and bots immediately with a "sold out / try later" page — pure admission control, not reinvented here (lesson 15; emergency shedding is lesson 13).
  3. 3. Sharded waiting room. Survivors are placed into a waiting room split across K shards so no single structure holds 1M entries. Each shard admits at a bounded rate.
  4. 4. Signed admission token. A bounded trickle of users is issued short-lived signed tokens. Only token-holders may call /checkout; the gateway rejects everyone else statelessly.
  5. 5. Atomic stock counter. A token-holder's checkout performs one conditional decrement: "subtract 1 only if remaining > 0." This is the single serialization point and the real oversell backstop.
  6. 6. Checkout-with-hold. A successful decrement creates a time-limited hold; the user completes payment within the hold timeout. On timeout, the hold expires and the unit returns to stock (C29's machinery).
1,000,000 arrivals ~1,000 succeed ───────────────── ────────────── ┌─────────── progressively cheaper rejection ───────────┐ [ clients ] │ 100k req/s ▼ ┌─────────────┐ sold-out / bot / flood │ CDN + EDGE │ ───────────────────────────▶ drop here (µs, zero origin) ~98k/s shed │ shed (L15) │ └──────┬──────┘ survivors ▼ ┌──────────────────────────────────────────┐ │ WAITING ROOM — sharded K ways (L07) │ park 1M without a hot key │ shard0 shard1 shard2 ... shardK │ each admits at bounded rate └──────┬─────────────────────────────────────┘ trickle out ▼ issue ┌──────────────┐ no token ⇒ 403, stateless reject │ SIGNED ADMIT │ ───────────────────────────▶ gateway verifies HMAC, no DB │ TOKEN │ └──────┬───────┘ token-holders only (~2k req/s) ▼ ┌──────────────────────────┐ │ ATOMIC STOCK COUNTER │ DECR IF remaining>0 ── single serialization point │ remaining: 1000 → 0 │ succeeds ≤ 1000 times TOTAL ──▶ else "sold out" └──────┬────────────────────┘ ▼ on success ┌──────────────────────────┐ │ CHECKOUT-WITH-HOLD (C29) │ create hold → pay within T → confirm | expire→return └──────────────────────────┘

The funnel's shape is the answer: each stage rejects what it can with the cheapest mechanism it has, so by the time a request reaches the DB-backed stock counter, traffic is already down from 100k req/s to ~2k req/s — and the conditional decrement caps it the rest of the way to exactly stock.

6. Deep dives

6.1 Gate before the scarce resource — fail cheaply, fail fairly

The cardinal rule: never let a request touch the scarce resource until it has earned the right to. If everyone hits /checkout directly, 1M requests contend on the stock row and the database dies before selling a single unit — the classic "everyone loses." The waiting room + signed token is admission control (lesson 15): it converts the unbounded 100k req/s arrival into a bounded ~2k req/s stream the checkout tier can actually serve. The token being signed is the load-bearing detail — the gateway validates it with an HMAC check and no state lookup, so the gate scales horizontally and adds near-zero latency. The economic asymmetry is the same one from lesson 15: a request that dies at the edge costs microseconds; a request that reaches a saturated database costs you the database and everything queued behind it. So you push the death of the 999,000 as far up the funnel and as cheap as possible.

6.2 Fairness under bot pressure — perfect fairness is impossible

"Fair" sounds simple until bots arrive. Two policies, both defensible:

The honest senior point: no admission policy is truly fair under adversarial load. A determined attacker with 10,000 accounts and residential proxies gets 10,000 lottery tickets or 10,000 fast FCFS attempts. Fairness mechanisms (lottery, per-account limits, proof-of-work/CAPTCHA, account-age/reputation gates) raise the cost of cheating; they don't eliminate it. Be explicit about the policy and that it is bot-resistance, not a fairness guarantee. Lottery is usually the better default for a hyped public sale precisely because it removes the speed dimension bots exploit.

6.3 Zero oversell while the DB sees only 1,000 of 1M requests

This is the sharpest question in the room. The answer rests on one primitive: an atomic conditional decrement. Reading the counter, checking it's positive, then writing back is the textbook lost-update race (DDIA Ch. 7) — two checkout workers both read remaining = 1, both decide they're fine, both decrement, and you've sold unit #1001. The fix is to make check-and-decrement a single atomic operation where the data lives:

-- atomic in one round trip (Redis Lua / single-row SQL): if remaining > 0 then remaining = remaining - 1 ; return OK else return SOLD_OUT -- equivalently in SQL: UPDATE stock SET remaining = remaining - 1 WHERE sale_id = ? AND remaining > 0; -- affected rows: 1 = won, 0 = sold out

Because the operation is atomic and conditional, it can succeed at most stock times no matter how many callers race on it — the database's own concurrency control (DDIA Ch. 9: single-leader serialization of writes to that one key) linearizes all 1M attempts onto a single integer that simply refuses to go below zero. This is why the DB "sees 1,000 of 1M": every other attempt returns SOLD_OUT from the counter check and never creates an order. The counter is the gate against oversell; the waiting room and token only shape how fast contention arrives, they don't enforce correctness.

One more layer for true durability: the decrement-then-pay flow can leave dangling holds (decrement succeeded, payment then failed or the worker crashed). So you reconcile orders against holds — every confirmed order must map to exactly one decrement, and expired/abandoned holds return their unit to the counter (C29's hold expiry + the idempotency/outbox machinery anchored in C27 and foundation lesson 12). The invariant: orders_confirmed + holds_active + remaining = total_stock, always.

6.4 Cache pre-warm and overload control

The cheapest request to serve is one the origin never sees. Pre-warm the CDN with the sale page, images, prices, and the JS-free countdown before the sale opens — a cold cache at T-0 means 1M misses stampede the origin (lesson 06). Serve /status from a short-TTL cache so polling clients never hit the DB. Then layer overload control behind the cache: per-account and per-IP rate limits at the edge (lesson 15), and load shedding / circuit breakers (lesson 13) so that if the checkout tier does saturate, it sheds with a clean "try again" rather than collapsing and dragging down the inventory store. Shed early (cheap edge 503/sold-out) rather than late (expensive timeouts deep in the stack).

7. Trade-offs

ChoiceBuysCostsChoose when
Waiting room + tokens vs open checkoutBounds backend load to ~stock-rate; protects the DBUX friction (a line), more moving partsOverload ratio >> 1 (the flash-sale case)
Lottery vs first-comeRemoves the speed dimension bots exploit; smooths the spikeNon-deterministic; surprises FCFS-trained usersHyped public sale with bot pressure
Static CDN page vs dynamic pageSurvives 1M views at ~0 origin loadLess personalizationSale launch / read-heavy spike
Atomic counter decrement vs optimistic order-then-checkZero oversell by constructionOne serialization point (hot key — shard or use a fast in-memory store)Always, for the stock invariant

The sharpest trade-off is the last row's hidden tension. The atomic counter guarantees correctness but is a single hot key under 1M contention (lesson 07). You resolve it not by weakening the invariant but by choosing where the counter lives: a single-threaded in-memory store (Redis) executing a Lua decrement handles tens of thousands of atomic ops/sec on one key with sub-millisecond latency — and crucially, after the waiting room throttles arrivals to ~2k req/s, the counter is no longer under 1M-wide contention anyway. The funnel and the atomicity work together: admission control shrinks the contention, the conditional decrement guarantees correctness on what's left.

8. Failure modes

FailureMitigation
Oversell (race on stock)Atomic conditional decrement (DDIA Ch. 7/9); reconcile orders against holds so the counter is always the truth.
Waiting-room hot keyShard the queue K ways (lesson 07) and admit via stateless signed tokens — no single structure holds 1M entries.
Bot floods / scalpersEdge rate limits + CAPTCHA/proof-of-work + per-account caps + account-age/reputation gates before admission.
Cold cache at T-0Pre-warm CDN and caches before the sale; serve /status from cache so polling never reaches the DB.
Payment slow / abandoned (hold leak)Short hold TTL; durable scheduled expiry returns stock to the counter (C29 hold machinery).
Checkout tier saturates anywayLoad shed with clean sold-out/503 at the edge, circuit-break the inventory store (lesson 13).

9. Interview prompts you should be ready for

  1. How do you guarantee zero oversell while the database only ever sees 1,000 of 1,000,000 requests? (senior answer) One atomic conditional decrement ("subtract 1 only if remaining > 0") is the gate. Single-leader write serialization (DDIA Ch. 9) linearizes all attempts onto one integer that refuses to go negative, so it succeeds at most stock times regardless of concurrency — eliminating the lost-update race (Ch. 7). Every losing attempt returns SOLD_OUT from the counter and never creates an order, so DB write volume tracks supply, not demand. Then reconcile orders against holds and expire abandoned holds back to the counter.
  2. What's the dominant constraint, in one number? (senior answer) The overload ratio. 1M demand for 1k stock is 1000:1, so 99.9% of requests must fail. The design's job is to make those failures as cheap and early as possible; success is bounded by stock no matter what.
  3. Why not just put everyone in one big queue? (senior answer) A single queue holding 1M concurrent entries is itself a hot key (lesson 07) — the thing you built to protect the DB becomes the first thing to fall over. Shard the waiting room K ways and admit a bounded trickle via stateless signed tokens.
  4. Lottery or first-come — which is fairer? (senior answer) Neither is truly fair under bots. FCFS rewards the fastest/closest (bots win) and concentrates the spike; lottery removes the speed dimension and spreads admission, so it's the better default for a hyped sale. Fairness mechanisms raise the cost of cheating; they don't guarantee fairness. Say so explicitly.
  5. Where does the rate limiter live and why? (senior answer) At the edge/gateway, before any scarce resource (lesson 15). A rejection there costs microseconds and zero downstream resources; a request admitted into a saturated DB costs the DB. Push the death of the 999,000 as far up and as cheap as possible.
  6. The stock counter is a single hot key — doesn't that violate "no hot keys"? (senior answer) Yes, by necessity — correctness requires a single serialization point. You resolve it by placement, not by weakening the invariant: an in-memory single-threaded store doing a Lua decrement handles tens of thousands of atomic ops/sec on one key, and the waiting room has already throttled arrivals to ~stock-rate so contention is small.
  7. How does this relate to the hotel reservation case (C29)? (senior answer) Same hold/reservation state machine and atomic conditional decrement — C29 is the anchor. The delta here is the overload ratio: hotels don't see 1000:1 spikes, so flash sale adds admission control and fairness in front of the same inventory primitive.
  8. Your stock counter (Redis) dies mid-sale — now what? (senior answer) Fail closed on the decrement (reject checkout) — never fail open, because that's exactly how you oversell. Use a replicated store so failover preserves the count, and reconcile against persisted holds/orders on recovery so the invariant orders+holds+remaining=stock still holds.

Related foundation lessons

This case is a satellite of C29 for the inventory-counter and hold mechanism, and leans on foundation lesson 15 for admission control rather than reinventing rate limiting. It uses the hot-key sharding of lesson 07 for the waiting room, the load-shedding and circuit breakers of lesson 13 for overload control, CDN/cache pre-warm from lesson 06, and the idempotency/reconciliation primitives anchored in C27 / foundation lesson 12.

C29 · Inventory hold anchor 15 · Admission control / token bucket 13 · Load shedding 07 · Hot-key sharding 06 · Caching / pre-warm 12 · Idempotency / reconciliation
Takeaway
A flash sale is an overload event with a fixed ratio: 1M demand, 1k supply, 1000:1, so 99.9% of requests are doomed before you write a line of code. The design's job is to kill those requests as early and as cheaply as possible — static CDN page, edge shed, sharded waiting room, signed admission tokens — so that the database only ever sees work proportional to stock, not traffic. The one hard invariant, zero oversell, rides entirely on a single atomic conditional decrement that linearizes a million attempts onto one integer that refuses to go below zero; the waiting room shrinks the contention, the atomicity guarantees the correctness. Be honest that fairness under bots is a cost you raise, not a property you guarantee.