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.
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.
1. Clarify the contract
State what the system must guarantee — and, just as important, what it explicitly need not.
- Zero oversell. Never sell unit #1001. This is the one hard invariant.
- Bounded backend load. The checkout/inventory path sees work proportional to stock, not traffic.
- Survivability. The product page and "you're in line / sold out" responses stay up under the full 1M arrival spike.
- Fair-ish admission. Some defensible policy (lottery or first-come) that bots can't trivially dominate.
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.
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 / operation | Why it exists |
|---|---|
GET /sale/{id} | Static, CDN-served sale page + a countdown. Must survive 1M hits with zero origin load. |
POST /sale/{id}/enter | Join the (sharded) waiting room; returns a position/ticket. Cheap, no inventory touched. |
POST /sale/{id}/checkout | Requires a valid signed admission token; attempts the atomic stock decrement + hold. |
GET /sale/{id}/status | Poll 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.
- Stock counter — a single integer (in Redis or a single-row DB cell) that is the source of truth for "units remaining." Every successful purchase is one atomic conditional decrement against it.
- Admission token — a short-lived, signed credential (HMAC/JWT) granting the holder the right to attempt checkout. Because it's signed, the gateway verifies it statelessly with no DB lookup — that's how the gate itself stays cheap.
- Hold — a time-limited reservation created the instant stock is decremented, so the unit is removed from sale while payment runs. This is exactly C29's hold/reservation state machine; we reuse it rather than reinvent it (see C29).
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. 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. 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. 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. 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. 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. 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).
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:
- First-come-first-served. Intuitive and what users expect, but it rewards whoever is closest and fastest — i.e. bots on low-latency hosts and scripts that fire at the millisecond the sale opens. Network jitter alone means "first" is partly luck. FCFS also concentrates the entire spike into the first instant, maximizing the hot-key problem.
- Random lottery. Everyone who entered within a window gets an equal draw for the limited admission tokens. This decouples success from speed, blunting the bot speed advantage and spreading admission over time (smoothing the spike). The cost: it's non-deterministic, so a user refreshing at 09:00:00.000 has no edge over one at 09:00:08 — which surprises users conditioned on FCFS.
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:
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
| Choice | Buys | Costs | Choose when |
|---|---|---|---|
| Waiting room + tokens vs open checkout | Bounds backend load to ~stock-rate; protects the DB | UX friction (a line), more moving parts | Overload ratio >> 1 (the flash-sale case) |
| Lottery vs first-come | Removes the speed dimension bots exploit; smooths the spike | Non-deterministic; surprises FCFS-trained users | Hyped public sale with bot pressure |
| Static CDN page vs dynamic page | Survives 1M views at ~0 origin load | Less personalization | Sale launch / read-heavy spike |
| Atomic counter decrement vs optimistic order-then-check | Zero oversell by construction | One 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
| Failure | Mitigation |
|---|---|
| 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 key | Shard the queue K ways (lesson 07) and admit via stateless signed tokens — no single structure holds 1M entries. |
| Bot floods / scalpers | Edge rate limits + CAPTCHA/proof-of-work + per-account caps + account-age/reputation gates before admission. |
| Cold cache at T-0 | Pre-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 anyway | Load shed with clean sold-out/503 at the edge, circuit-break the inventory store (lesson 13). |
9. Interview prompts you should be ready for
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.