Courses › System Design › Question Breakdowns

Design a Flash Sale

In-memory stock gates, waiting rooms with admission tokens, no overselling under failover, bots and isolation.

Design a flash sale system — a limited drop like a sneaker release, a Shopify merchant's 10,000-unit launch, or a Prime Day lightning deal: millions of shoppers hit "Buy" at the same second for a small fixed stock. It's a question about extreme write contention on a few inventory rows, absorbing a traffic spike far beyond normal capacity, never overselling, fairness, and bots.

Requirements

Functional

  • A sale has a start time, a product with fixed stock (e.g. 10,000 units) and a per-customer limit
  • Shoppers reserve a unit, then pay within a time limit; unpaid reservations return to stock
  • Shoppers see whether they got one — quickly and honestly — and the remaining availability
  • The rest of the store keeps working during the sale

Out of scope: the normal catalog and checkout (see Design an Online Bookstore), shipping, payment internals.

Non-functional

  • No overselling, ever: sold units ≤ stock
  • Spike tolerance: ~1M users arriving within seconds, ~100k+ "Buy" requests/second at the start — roughly 100× normal traffic
  • Fairness: first come, first served (or a fair lottery); bots shouldn't win everything
  • Fast, clear answers: users learn "you got it" / "sold out" within seconds
  • Isolation: the sale can't take down the rest of the platform

[!TIP] Most requests must fail fast and cheaply With 10,000 units and 1M shoppers, 99% of requests will be told "sold out". The design goal is to reject them as early and cheaply as possible — at the edge or in memory — so only a trickle of real orders reaches the database.

Capacity estimates

Core Entities

  • Sale — product, start time, stock, per-user limit, status
  • Stock counter — remaining units (the hot spot)
  • Reservation — sale, user, status (reserved / paid / expired), expires at
  • Order — created on payment, as in normal checkout

API

GET  /v1/sales/{saleId}            → { "startsAt": "...", "status": "live", "available": true }   (CDN-cached, short TTL)
POST /v1/sales/{saleId}/reserve    Idempotency-Key: ...   (with a sale token from the waiting room)
→ 201 { "reservationId": "r_1", "expiresAt": "..." }  |  409 { "error": "sold_out" }
POST /v1/reservations/{id}/checkout   { "paymentMethod": "..." }

High-Level Design

user = client "Shoppers" @0,1
cdn = cache "CDN / Edge\n(static page, bot checks)" @1,0
wait = service "Waiting Room\n(queue + tokens)" @1,1
gate = service "Reservation Service" @2,1
stock = cache "Stock Counter\n(Redis, atomic)" @3,0
q = queue "Reservation Queue" @3,1
orders = service "Order Workers" @4,1
db = store "Orders / Inventory DB" @4,2
pay = service "Checkout + Payment" @3,2
user -> cdn : sale page
user -> wait : join
wait -> gate : admitted with token
gate -> stock : DECR if > 0
gate -> q : reserved
q -> orders
orders -> db
user -> pay : pay within 10 min
pay -> db
  • The sale page is static and served from the CDN; bots are filtered at the edge.
  • At start time, shoppers enter a waiting room that admits them at a rate the backend can handle, issuing signed admission tokens.
  • The reservation service decrements an in-memory stock counter atomically; success means a unit is held for that user, failure means sold out.
  • Successful reservations go through a queue to workers that write them durably; the user then pays within the time limit.
  • Expired reservations put units back into the counter.

Deep Dives

1. Why the database can't be the gate

UPDATE inventory SET stock = stock - 1 WHERE product_id = :p AND stock > 0;

Correct, but every request locks the same row: a database handles a few thousand such updates per second, with requests piling up behind the lock and timing out. Move the gate to an atomic in-memory counter:

local left = tonumber(redis.call('GET', KEYS[1]))
if left and left > 0 then
  redis.call('DECR', KEYS[1])
  redis.call('SADD', KEYS[2], ARGV[1])   -- record the buyer (enforces one per user)
  return 1
end
return 0

A single Redis key handles ~100k ops/s. For more, split stock into buckets (10 counters × 1,000 units) and route users to a bucket by hash, falling back to other buckets when theirs is empty.

2. Keeping the counter and the database in agreement

The counter is fast but volatile; the database is durable. Treat the counter as the admission gate and the database as the record:

  • A successful decrement enqueues a reservation (durable queue) before responding; workers insert it with a unique (sale, user) constraint.
  • If Redis fails over and loses recent decrements, the database still enforces the hard cap (a conditional insert that counts reservations ≤ stock), so overselling is impossible even then — at worst a few users are told "sold out" wrongly.
  • Expired reservations: a sweeper marks them expired in the DB and INCRs the counter.

[!WARNING] Return units exactly once If both a timeout sweeper and a late payment-failure handler return the same unit, stock grows past the real amount and you oversell. Make the return a conditional state change (reserved → expired) and increment the counter only when that update succeeds.

3. The waiting room

A virtual queue turns a 1M-user stampede into a steady flow:

  • Users who arrive before or at the start get a random or arrival-ordered position; the page polls cheaply (from the edge) for its turn.
  • Admission rate is set to what the reservation service can handle; admitted users receive a short-lived signed token required by /reserve.
  • When the counter hits zero, the waiting room tells everyone "sold out" immediately — no one needs to reach the backend.

4. Fairness and bots

  • Per-user and per-payment-method limits enforced in the Lua script and the DB.
  • Bot defenses at the edge: rate limits per IP/device, challenges, purchase history signals; tokens bound to the session so they can't be shared.
  • Some drops use a lottery: collect entries during a window, then randomly pick winners — fairer than milliseconds-matter racing and much easier on the system.

5. Isolation

Run the sale on dedicated capacity (separate reservation service instances, Redis, queues) with its own rate limits, so a sale spike can't exhaust the shared checkout or catalog services. Pre-scale before the start time; autoscaling is too slow for a spike that peaks in seconds.

What's expected at each level

  • Mid-level: an atomic stock decrement, reservations with expiry, a CDN for the sale page.
  • Senior: moving the gate to an in-memory counter, durable reservation queue with DB uniqueness, waiting room with admission tokens, returning expired units safely.
  • Staff: counter/DB consistency under failover, bucketed counters, bot defense and lottery alternatives, isolation and pre-scaling, and honest UX when stock runs out.