SYSTEM DESIGN · DESIGN PROBLEMS · COMMERCE AND MONEY · HARD · 29 OF 41

Design a Flash Sale: Ten Thousand Units, Ten Million Buyers, No Overselling

Design a sale where ten thousand phones go on sale at noon and ten million people click at once. Requirements and a burst of millions of requests a second, the claim API and data model, why one inventory row melts, then the hard parts: a filtering funnel at the edge, atomic stock claims in Redis with split buckets, asynchronous order creation through a queue, payment windows that return stock and reconciliation.

Reported atGoldman SachsFlipkart

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 18

Press play to hear the narration, or step through at your own pace.

Notes

Listen

Pin down the rules. The sale opens at a fixed time with a fixed stock of ten thousand units, one per customer. A winner gets a reservation and has ten minutes to pay, and unpaid units go back on sale. Non-functional: never oversell, be fair, which mostly means keeping bots out, and keep the rest of the store healthy while this happens. Out of scope: the product catalog and shipping.

Requirements
  • Functional
  • sale opens at a fixed time
  • fixed stock: 10,000 units
  • limit 1 per customer
  • winner pays within 10 min
  • unpaid units go back on sale
  • Non-functional
  • never oversell
  • fair: no bots, first come
  • rest of the store stays up

Ask scale

Ten million people sit on the sale page. If a fifth of them click in the first second, that is two million requests, and retries and refreshes can double it. Yet only ten thousand claims can ever succeed: about four hundred requests for every unit. Compare that to what storage can take. A single hot database row that is locked on every update manages perhaps a thousand updates a second. A single Redis shard does around a hundred thousand simple operations a second. That gap is the design.

Back of the envelope
10M users waiting on the page
at 12:00:00, 20% click in 1 s
  = 2M requests in the first second
  retries and refreshes double it

useful writes: 10,000 claims total
  ratio: about 400 requests per unit

one SQL row, locked per update:
  about 1,000 updates/s at best
Redis shard: about 100,000 ops/s

Define API

The sale page is static and served by the CDN, including the server's clock so the countdown is honest. Entering gives you a signed queue token. Claim takes that token and an idempotency key and answers immediately: accepted with a claim id, or 410 sold out. Then the client polls the claim, which becomes reserved with a pay-by time, or lost. Paying is a separate call. Splitting claim from payment is what lets us answer millions of people quickly.

API
GET  /v1/sales/{id}
  static, CDN; server_time, starts_at
POST /v1/sales/{id}/enter
  -> { queue_token, position }
POST /v1/sales/{id}/claim
  Idempotency-Key, queue_token
  -> 202 { claim_id }  | 410 sold out
GET  /v1/claims/{id}
  -> PENDING | RESERVED(pay_by) | LOST
POST /v1/orders { claim_id, payment }

Data model

Two stores with different jobs. Redis holds the hot path: the remaining stock, split into ten bucket keys of a thousand units, and next to each bucket a set of user ids who already claimed. SQL holds the truth: the sale definition, one reservation row per winner with a unique constraint on sale and user, and the final orders. Redis answers the stampede; SQL is what accounting and customer support trust.

Two stores
Redis (fast path):
  sale:77:stock:b0..b9 -> 1,000 each
  sale:77:buyers:b0..b9 -> user_ids

SQL (source of truth):
  sales(id, sku, starts_at, total)
  reservations(id, sale_id, user_id,
    status, expires_at)
    UNIQUE (sale_id, user_id)
  orders(id, reservation_id, paid_at)

High-level design

Start with the naive design so you can show why it fails. Every click runs a conditional update decrementing stock on one row. It is perfectly correct and never oversells. But every update locks the same row, so they run one at a time, around a thousand a second, while two million wait. Connection pools fill up, the database that serves the rest of the store slows down, and users retry, doubling the load. Correct, but it falls over.

Now the real design, a funnel. The CDN serves the page and countdown, so page loads never reach us. The edge gate drops bots and excess requests, letting through only a few times the stock. The sale service claims a unit with one atomic operation in Redis. Winners go onto a Kafka queue, and workers write reservations to the database at a steady, comfortable rate. Each layer passes on a small fraction of what it received.

Why it melts
UPDATE inventory SET stock = stock - 1
 WHERE sku = 42 AND stock > 0;

correct, never oversells, but:
  every update locks the same row
  ~1,000/s, queue grows to millions
  connection pool exhausted
  whole store DB slows down
  users retry: load doubles

Deep dive: the gate

The gate is the cheapest place to say no. Everything static, including the countdown, lives on the CDN, and the claim button enables from the server's clock, not the phone's. Require a signed entry token from pre-registration or a captcha, which filters most bots. A token bucket at the edge admits roughly five times the stock per second and returns a friendly try again page to the rest. Limit per account, device, card and IP.

Edge gate
  • static page + countdown on CDN
  • claim button enables at noon
  • using server time, not device
  • pre-registration or captcha
  • issues a signed entry token
  • token bucket at the edge:
  • admit about 5x stock per second
  • per account, device, card, IP
  • over limit: "try again" page

Deep dive: atomic claim

The claim itself is one small Lua script, which Redis runs atomically, so no other command interleaves. If the user is already in the buyers set, return already claimed. If this bucket's stock is zero, return empty. Otherwise decrement, add the user to the buyers set, and append a record of the claim. One round trip, a few microseconds of work, and no race is possible between checking and decrementing.

Claim script (runs atomically)
if SISMEMBER {b3}:buyers user then
  return ALREADY
if GET {b3}:stock <= 0 then
  return EMPTY
DECR {b3}:stock
SADD {b3}:buyers user
XADD {b3}:claims {user, claim_id}
return OK

one round trip, no race possible

Deep dive: hot key

Even Redis has a ceiling: one key lives on one shard, served by one core. So split the stock into ten buckets of a thousand units, each on a different shard. Each user hashes to a home bucket and, if it is empty, tries the next two in a fixed order. Keep each bucket's buyers set beside its stock under one hash tag, since a Lua script can only touch keys on one shard; the SQL unique constraint is the final one-per-user guard. This is the general trick for any hot counter, covered in the Scaling writes pattern.

Splitting the stock
one key = one shard = one CPU core
  about 100,000 ops/s ceiling

10 buckets x 1,000 units,
  spread over 10 Redis shards
home bucket = hash(user) mod 10
  EMPTY -> next 2 in fixed order

{b3}:stock + {b3}:buyers: same hash
  tag, so the script hits 1 shard

Deep dive: async orders

Once Redis has picked the winners, nobody needs the database to answer in microseconds. Each successful claim becomes an event on Kafka. Workers consume them and insert reservation rows at a few hundred a second; the unique constraint on sale and user turns any duplicate event into a harmless no-op. The claim status moves from pending to reserved and the client sees it by polling or a push. Ten thousand rows land in well under a minute.

Queue to the database
claim OK -> event on Kafka
  key = user_id, has claim_id
workers: INSERT reservation
  (sale_id, user_id) UNIQUE
  duplicate event -> no-op
  rate: a few hundred per second
claim status: PENDING -> RESERVED
  client polls or gets a push

10,000 rows in under a minute

Deep dive: pay window

A reservation gives the winner ten minutes to pay. Paying marks it paid only if it is still reserved and inside the window, so a late payment after expiry is refunded instead of overselling. Every thirty seconds a sweeper expires lapsed reservations and adds those units back to a Redis bucket, so another buyer can claim them. Whether the person who did not pay may try again is a product decision; most sales say no.

Ten minutes to pay
RESERVED, pay_by = claimed + 10 min
pay -> UPDATE reservation
  SET status = PAID
  WHERE id = :r AND status = RESERVED
    AND pay_by > now()

sweeper every 30 s:
  expired -> status = EXPIRED
  INCR a stock bucket in Redis
  remove user from buyers set?
    policy: usually no second try

Deep dive: correctness

Redis makes decisions fast, but SQL has the final word. Turn on Redis persistence and keep a replica in another zone. If Redis is lost mid-sale, pause claims and rebuild the remaining stock as the total minus the reservations already in SQL. The workers carry a second guard: refuse to insert a reservation once reserved plus paid equals the total. After the sale, reconcile Redis claims against SQL rows. Overselling now needs two independent systems to be wrong at once.

Never oversell, even on failure
  • Redis persistence on (AOF),
  • replica in another zone
  • Redis lost mid-sale: pause
  • claims, rebuild stock from
  • total - reservations in SQL
  • worker guard: refuse insert if
  • reserved + paid = total
  • after the sale: reconcile
  • Redis claims vs SQL rows

Deep dive: isolation

Isolate the sale. Give it its own service, Redis, queue and capacity, so a stampede cannot starve normal shopping. Scale up half an hour before noon, because autoscaling reacts in minutes and the spike lasts seconds. Warm the CDN and caches. Keep a kill switch in the sale config to pause claims instantly. And load test at twice the expected peak a week before, because the first real test should never be the sale itself.

Contain the blast radius
  • separate sale service, Redis,
  • queue and autoscaling group
  • pre-scale 30 min before noon
  • (autoscaling is too slow)
  • warm caches, CDN, DNS, TLS
  • kill switch in sale config
  • load test at 2x expected peak
  • rest of the store keeps its
  • own databases and limits

Trade-offs

Name the trade-offs. Users get a quick pending answer and a final answer seconds later, which is the price of the queue. Pure first come rewards the fastest bots, so some sales use a short entry window followed by a random draw, which is often fairer. More buckets mean more throughput but a messier moment when stock runs out. And stricter bot checks cost real customers friction.

During the sale, watch the gate's admit rate, claims per second, stock left per bucket, queue lag, Redis CPU and reservations against the total. Afterwards, check the oversell count, which must be zero, the share of winners who paid, and how many accounts looked like bots. With ten times more buyers, only the gate gets stricter; the backend sees the same traffic. With ten times more stock, add buckets.

Trade-offs
  • fast answer vs final answer:
  • claim says PENDING first
  • first come vs lottery:
  • a 30 s entry window + random
  • draw is fairer than raw speed
  • more buckets: more throughput,
  • messier sold-out moment
  • strict bot checks: fewer bots,
  • more friction for real people
Watch and grow
  • watch live: gate admit rate,
  • claims/s, stock left,
  • queue lag, Redis CPU,
  • reservations vs total
  • after: oversell count (0),
  • paid ratio, bot share
  • 10x buyers: tighter gate,
  • same backend
  • 10x stock: more buckets

Interview summary

In the interview, say this. Two million requests a second for ten thousand units, so a single database row cannot be the gate. Build a funnel: CDN, an edge gate with tokens and bot checks that admits a few times the stock, an atomic Lua claim in Redis with the stock split into buckets, a queue that writes reservations to SQL with a unique constraint, and a payment window with a sweeper and reconciliation.

Say it like this
  • 2M req/s for 10,000 units
  • one hot SQL row melts
  • funnel: CDN, gate, Redis,
  • queue, SQL
  • gate: tokens, bot checks,
  • admit about 5x stock
  • Lua claim: buyer + stock check
  • split stock into buckets
  • async reservations, UNIQUE
  • pay window, sweeper, reconcile

Interview questions

Why not just decrement stock in the database with a conditional update?

It is correct, but every request locks the same row, so throughput is limited to roughly a thousand updates per second while millions of requests queue, exhausting connections and slowing the rest of the store.

How does Redis prevent overselling?

A Lua script runs atomically on the Redis shard, checking that the user has not already claimed and that stock is above zero before decrementing, so there is no gap between the check and the update for another request to slip into.

What do you do about a single hot key in Redis?

Split the stock into several bucket keys on different shards and send each user to a home bucket by hash, trying a couple of others in a fixed order if it is empty; keep each bucket’s buyers set on the same shard so the script stays atomic. This multiplies throughput at the cost of a slightly uneven sold-out moment.

Why create orders asynchronously?

The winners are already decided in Redis, so the database only needs to record ten thousand reservations, which a queue lets workers write at a steady rate. Idempotent inserts with a unique constraint make duplicate events harmless.

How do you keep bots from buying everything?

Require pre-registration or a captcha that issues a signed entry token, limit claims per account, device, payment card and IP, and consider an entry window followed by a random draw instead of pure speed.

What happens to units that winners never pay for?

Each reservation has a payment deadline; a sweeper marks lapsed ones expired and returns their units to a Redis stock bucket so others can claim them, and payment after the deadline is refunded.

What if Redis fails during the sale?

Pause claims, then rebuild remaining stock from the total minus reservations recorded in the database, which is the source of truth. Persistence and a cross-zone replica make this rare, and a worker-side cap prevents overselling meanwhile.

How do you stop the sale from taking down the rest of the store?

Run it on isolated infrastructure with its own service, cache and queue, pre-scale before the start time, serve static content from the CDN, and keep a kill switch to pause claims.