A flash sale sells a small, fixed number of identical units at a price nobody else will match, starting at a moment everyone knows in advance: 10,000 units of a new phone at ₹9,999 at 12:00 noon on the first day of Big Billion Days, or a doorbuster on Singles’ Day, 11.11. Five million people have the page open at 11:59:59. The stock is gone within seconds, and 4.99 million of them must hear “sold out” without the rest of the store noticing. The design is interesting because it turns the usual shop upside down: almost every request must be answered no, cheaply, while exactly 10,000 must be answered yes — never 10,001. Getting it wrong is public. When one cosmetics brand ran a sale on Shopify in 2016, it took down not only its own store but every other store on the same database shard.
Understand the problem
Pin down the inventory first. All 10,000 phones are identical, so stock is a number, not a set of records: a counter that goes from 10,000 to 0 and never below. That is the difference from Ticketmaster, where every seat is a distinct row that two fans can fight over. A counter is simpler to model and harder to scale, because every buyer in the country writes to the same number at the same instant.
Functional requirements
- Users should be able to see the sale page with a countdown and whether stock is left. The countdown follows the server’s clock, and “sold out” appears within a second or two of it happening.
- Users should be able to claim one unit when the sale opens — at most one per customer — and get a clear yes or no straight away.
- Users who claim a unit should be able to place the order and pay within 10 minutes.
- Users who missed out should be able to buy units that winners don’t pay for. Unpaid claims expire and the units go back on sale.
Non-functional requirements
- Never oversell. Paid orders stay at or below 10,000 through crashes, retries and cache failovers. The acceptable error is zero: in CAP terms, the stock decision is CP — refuse to sell rather than guess.
- Don’t strand stock. A unit from an expired claim is back on offer within seconds; by the end, essentially every unit with a willing buyer is sold.
- Absorb the spike. Five million people on the page and about 2 million claim requests a second at noon (derived below), for an item with 10,000 units.
- Answer fast, even when the answer is no. A claim answered in under 500 ms at p99; “sold out” from the edge in under 100 ms.
- Keep the rest of the store healthy. Ordinary browsing and checkout — about 1,200 orders a second at a sale peak in the online store — must not notice the flash sale.
- Be fair. One unit per customer. Clicking at 12:00:00.1 instead of 12:00:00.4 shouldn’t decide who wins, and neither should owning a bot farm.
- Durable once confirmed. An order is never lost once created (RPO 0), and a “you got it” should almost never be taken back.
In the interview
Ask three questions before drawing: “How many units, and how many people?”, “One per customer?” and “Must it be first come, first served, or may we run a draw?” The last one changes the design most. A draw lets you admit only as many people as you have units; a pure race makes your stock counter take the whole crowd.
Out of scope
- Distinct seats and seat maps. Those belong to Ticketmaster, along with the full mechanics of a virtual waiting room.
- Payment internals. PSPs, UPI flows, retries with unknown outcomes and refunds are in design a payment system; here you only care how a payment meets a claim.
- The rest of the store. Catalogue, search and product pages are the online store’s problem.
- Normal cart and checkout. Coupons, tax and cart merges live in checkout and cart; a flash-sale claim skips the cart.
- Fulfilment and delivery, which start once the order is paid.
The set-up
Plan the approach
Say the shape early: “a funnel in front, a counter in the middle, a pipeline behind.” The funnel says no cheaply; the counter says yes exactly 10,000 times; the pipeline turns yeses into orders and payments at a pace the store can take. Build the four requirements simply, with stock as a row in a database, then spend the deep dives on the counter, the funnel, the order pipeline, expiry, bots and isolation.
Core entities
- Sale.
fs1111: the SKU, price ₹9,999 (999900paise), 10,000 units, opening at 12:00:00 IST, a limit of one per customer, a 10-minute payment window and a state (scheduled, live, all claimed, sold out, ended). - Stock. How many units are left: one number in the simple design, eight bucket counters later.
- Sale pass. A signed token saying that a registered shopper may buy in this sale: user, device, sale, and a validity window of 12:00:00 to 12:15:00.
- Claim. One shopper’s hold on one unit: claim ID, user, bucket, pay-by time and state.
- Order. Created from a claim: order ID, claim ID, amount and status. The payment pays for this.
API
Reading the sale is cacheable:
GET /v1/sales/fs1111 → name, price, images, opens_at (CDN, cached for minutes)
GET /v1/sales/fs1111/status → { "state": "LIVE", "stock": "AVAILABLE",
"server_time": "2026-11-11T11:59:57.204+05:30" } (CDN, 1 s)
POST /v1/sales/fs1111/passes → a signed sale pass, issued before noon after identity checks
Claiming is the write that must be exactly right:
POST /v1/sales/fs1111/claims
Authorization: Bearer <session token>
Sale-Pass: <signed pass: user u_42, sale fs1111, valid 12:00–12:15>
Idempotency-Key: 0d1f6c52-7a7e-4c1b-9b8e-2f4f3d9a6c11
{
"claim_id": "cl_9f2c",
"status": "CLAIMED",
"pay_by": "2026-11-11T12:10:00+05:30",
"order": { "order_id": "ord_9f2c", "status": "CREATING" }
}
A new claim returns 201 Created. A retry, or a second claim by the same shopper, returns the same body with 200. If nothing is left, 409 Conflict carries "error": "sold_out" and a waitlist position. Then:
GET /v1/claims/cl_9f2c → { "status": "ORDERED", "order_id": "ord_9f2c", "pay_by": "…" }
POST /v1/orders/ord_9f2c/payments → a payment intent (see the payment system)
Back-of-envelope estimates
Assume 10,000 units, 6 million registered shoppers, 5 million of them on the page at noon, one unit each, and a 10-minute payment window. (The method is in back-of-envelope estimation.)
Before noon. Each open page polls the status object every 10 seconds: 5M ÷ 10 s = 500,000 requests/s, all for the same few bytes — a job for the CDN.
At noon. Say 80% of them tap Buy within two seconds: 5M × 0.8 ÷ 2 s = 2M claim requests/s. If the app retried every 500 ms on an error, those 4M people would send 4M × 2 = 8M requests/s — a retry storm you have built yourself.
Oversubscription. 4M clickers ÷ 10,000 units = 400 people per unit; 99.75% of them will lose.
One database row. A conditional UPDATE holds the row lock until it commits: about 0.5 ms to flush the write-ahead log, 1 ms for a synchronous standby to confirm, and 0.5 ms of statements and round trips — about 2 ms. One row therefore takes 1 ÷ 0.002 s = 500 claims/s, and selling 10,000 units takes 10,000 ÷ 500 = 20 s, while 2M × 20 = 40M requests arrive.
Redis. One Redis primary runs commands on one thread. Its own benchmark shows about 180,000 plain SETs a second against a single key; a five-command claim script costs several times a SET, and you want CPU near 50%, so budget 40,000 scripts/s per primary. Taking 200,000 attempts a second (the edge’s ceiling, below) needs 200,000 ÷ 40,000 = 5 primaries; use 8 buckets of 1,250 units for headroom, 25,000 attempts/s each.
Orders. 10,000 orders arrive within about a second. Fed through a queue at 1,000 orders/s, they drain in 10 s; at about five writes each, that is 5,000 writes/s.
Payments. If 85% of winners pay, mostly in the first three minutes: 8,500 ÷ 180 s ≈ 47 payments/s — under 1% of the payment system’s 5,200/s sale peak. Payment is not the bottleneck.
Releases. The other 15% — 1,500 units — come back, nearly all at 12:10:00, because nearly everyone claimed at 12:00:00.
Tip
The defining number of a flash sale is not QPS but the ratio: 400 people per unit. Design to say no four million times for almost nothing, and yes 10,000 times exactly.
High-level design
Build the design one requirement at a time, with the stock as a row in a relational database. By the end it works for a few hundred units and a few thousand shoppers; the deep dives make it survive noon.
Users should be able to see the sale page with a countdown and stock status
The page is the same for everyone, so it is a CDN object cached for minutes. The only live part is a tiny status object — sale state, whether stock is left, the server’s time — which the sale service builds from the Sale DB and the CDN caches for one second, so the origin answers about once a second per shield location however many people poll.
The countdown is the subtle part. Phone clocks are often seconds out, sometimes minutes. The status reply carries server_time; the app measures the round trip, sets offset = server time + half the round trip − local time at receipt, and counts down against its own clock plus that offset. The Buy button lights up at noon by that corrected clock, but nothing relies on it: the server rejects claims made before 12:00:00 by its own clock, so a phone running three minutes fast gains nothing.
Users should be able to claim one unit when the sale opens
A tap on Buy goes through the API gateway, which authenticates the shopper, to a claim service. The claim is one short transaction on the Sale DB:
BEGIN;
UPDATE sale_stock SET remaining = remaining - 1
WHERE sale_id = 'fs1111' AND remaining > 0;
-- 0 rows: sold out. ROLLBACK and return 409.
INSERT INTO claims (claim_id, sale_id, user_id, pay_by, state)
VALUES ('cl_9f2c', 'fs1111', 'u_42', now() + interval '10 minutes', 'CLAIMED');
-- unique (sale_id, user_id) violated: this shopper already has one.
-- ROLLBACK (the unit goes back) and return their existing claim.
COMMIT;
The check and the decrement are one statement, so there is no gap between reading “1 left” and writing “0 left”. If two claims arrive together, PostgreSQL makes the second UPDATE wait for the first to commit and then re-evaluates its WHERE clause against the new row; at zero, it updates nothing. The unique constraint enforces one per customer, and because both writes share a transaction, a duplicate never consumes a unit. Overselling is impossible here — the problem is speed, as the first deep dive shows.
Users who claim a unit should be able to place the order and pay within 10 minutes
In this simple version the claim service calls the order service in the same request, which writes the order — ₹9,999, pending payment, pay by 12:10 — to the Orders DB. When the shopper taps Pay, the payment service creates a payment intent keyed by the order ID and hands the shopper to the PSP for UPI or a card; the PSP’s webhook reports the outcome and the order is confirmed. Intents, retries and late webhooks belong to design a payment system; here the payment is a black box that eventually says “paid”.
Users who missed out should be able to buy units that winners don’t pay for
A sweeper runs every 30 seconds. In one transaction it marks claims past their pay-by time as expired — only those still in the CLAIMED state — adds the same number of units back to the stock row, and asks the order service to cancel the matching orders. The status object flips back to “available”, and anyone still on the page can claim again. Because the expiry is a conditional update, a payment that completes at 12:09:59 and a sweep at 12:10:00 can’t both win: whichever changes the state first decides.
This works end to end. At noon with five million people, nearly every box breaks. One stock row serialises every claim at the speed of a commit. The claim service creates orders synchronously, so 10,000 winners hit the order pipeline in the same second. Nothing stops 4 million losers reaching the database just to hear no, nothing stops bots, and the Sale DB is a database the rest of the store may share. The deep dives fix each in turn.
Deep dives
How do we decrement stock without overselling at 200,000 attempts a second?
Take the worst case: no draw, just a race, with the edge forwarding 200,000 claims a second. The funnel in the next deep dive usually keeps the number far lower, but a misconfigured funnel, a deal run as a plain race or twenty deals opening at noon all land here.
Bad: read the stock, then write it. Read remaining = 1, check it is positive, write 0. Two claims that read before either writes both see 1, both write 0, and two shoppers own one phone — the lost update. At 200 attempts per millisecond this is not a rare race; it happens on most units.
Good: one conditional UPDATE — correct, but one row. This is the high-level design, and it never oversells. Its ceiling is the row lock, held from the UPDATE until the commit is durable. With a 2 ms commit, one row does 1 ÷ 0.002 = 500 claims a second, however many cores the server has. Selling 10,000 units takes 20 seconds. Meanwhile 200,000 attempts a second pile up: a pool of 500 connections fills in 500 ÷ 200,000 = 2.5 ms, and every other query on that database — including ordinary orders, if it is shared — queues behind them. Specialised engines help: Alibaba Cloud’s MySQL service has an “inventory hint” built for flash sales that queues hot-row updates inside the engine and commits on success, rated at up to 31,000 single-row updates a second on a very large instance. Even that is six times short, and every losing attempt still costs a database round trip.
Better: an atomic counter in Redis. Redis runs commands one at a time on one thread, so DECR is atomic and returns the new value. “DECR; if below zero, INCR back and say sold out” never oversells, but the counter dips negative in the rush, a crash between the two commands loses a unit, and the one-per-customer check is a separate command that two taps from one shopper can both pass. Fold the whole decision into one Lua script, which Redis runs atomically with nothing in between:
-- Claim one unit from one stock bucket. Every key shares the hash tag {fs1111:b3}.
-- KEYS[1] stock remaining units in this bucket
-- KEYS[2] winners hash: user ID -> claim ID
-- KEYS[3] deadlines sorted set: claim ID scored by its pay-by time (ms)
-- ARGV[1] user ID ARGV[2] new claim ID ARGV[3] pay-by time (ms)
local existing = redis.call('HGET', KEYS[2], ARGV[1])
if existing then
return {'ALREADY_CLAIMED', existing} -- retries get the same answer
end
local left = tonumber(redis.call('GET', KEYS[1]) or '0')
if left <= 0 then
return {'EMPTY', ''}
end
redis.call('DECR', KEYS[1])
redis.call('HSET', KEYS[2], ARGV[1], ARGV[2])
redis.call('ZADD', KEYS[3], ARGV[3], ARGV[2])
return {'CLAIMED', ARGV[2]}
The script decrements only when stock is positive, so the counter never goes negative; it records the winner and the deadline in the same step; and a retried request gets its original claim back instead of a second unit. It runs as EVALSHA <sha> 3 fs:{fs1111:b3}:stock fs:{fs1111:b3}:winners fs:{fs1111:b3}:deadlines u_42 cl_9f2c <pay-by>.
The ceiling is the thread. One key lives on one primary, and the budget of 40,000 scripts a second from the estimates is a fifth of the 200,000 you need. Replicas don’t help, because every claim is a write; a bigger machine doesn’t help either, because the work runs on one core.
Great: split the stock into buckets. Divide the 10,000 units into 8 buckets of 1,250, each with its own hash tag — {fs1111:b0} to {fs1111:b7} — so Redis Cluster places them on 8 different primaries. The claim service sends each shopper to bucket hash(user ID) mod 8: 200,000 ÷ 8 = 25,000 scripts a second per primary, well inside budget. Because a shopper always lands on the same bucket, the one-per-customer check stays inside one atomic script.
What you pay:
- Uneven draining. With about 1,250 winners per bucket, random hashing leaves the counts roughly √1,250 ≈ 35 units (3%) apart, so the buckets empty within milliseconds of each other. If a shopper’s home bucket is empty while others aren’t, the claim service tries one random non-empty bucket, then marks the shopper in their home bucket. A duplicate is possible between those two writes; the database’s unique constraint catches it at order creation.
- Rebalancing later in the sale. When units trickle back from expired claims, a quiet bucket can sit on stock while busy ones are empty. A rebalancer moves units with a script that subtracts only if enough are there, then adds them to the target bucket. A crash between the two loses units — undersell, fixed by reconciliation — and never creates them.
- An approximate “units left”. Displaying it means summing eight counters, which is fine for a progress bar.
Also great: pre-generated tokens. Instead of a number, put 1,250 unit tokens (u-04812) in a Redis list per bucket and LPOP one per claim; an empty list means sold out, and a return is an RPUSH. Every unit now has an identity, so reconciliation is exact: each token must end in exactly one order or back in a list. It costs about 10,000 × 50 bytes = 500 KB and has the same throughput as a counter, so you still bucket it.
| SQL conditional update | Redis script, one key | Redis buckets (8) | Token lists (8) | |
|---|---|---|---|---|
| Never oversells | Yes | Yes | Yes | Yes |
| Claims per second | ≈ 500; 31,000 with engine hints | ≈ 40,000 budget | ≈ 320,000 budget | ≈ 320,000 budget |
| One per customer | Unique constraint, same transaction | Same script | Same script, home bucket | Same script |
| Blast radius | The database it lives in | One Redis primary | Eight primaries, sale only | Eight primaries, sale only |
| What you pay | Stalls everything on that database | One thread is the ceiling | Rebalancing, approximate display | Memory per unit, still bucketed |
In the interview
Say the reason, not just the tool: “A row lock serialises claims at the speed of a commit — about 2 ms, so 500 a second. Redis serialises them at the speed of a script, microseconds. Buckets let me run eight of those queues side by side, and routing by user keeps one per customer inside a single script.”
How do we keep 5 million people away from the stock counter?
Even 320,000 claims a second is a sixth of the 2 million arriving, and almost all of those requests cannot succeed: 4 million people, 10,000 units. Work backwards from the counter. Ideally it sees about as many attempts as there are units, and everyone else gets their answer without reaching it.
Bad: let everyone through and autoscale. The spike arrives in one second; autoscaling reacts in minutes. And capacity doesn’t create phones — more servers only queue more losers in front of the same counter.
Good: rate-limit at the edge to what the counter can take. A token bucket at 200,000 claims a second, leased out to edge locations (see design a rate limiter), keeps the counter alive. As a way of choosing buyers it is poor: nine in ten callers get a 429 and retry, the winners are whoever retries fastest — usually bots — and it keeps forwarding attempts after the stock is gone. Shopify’s first checkout throttle for flash sales was this layer: a leaky bucket at its Nginx and Lua edge that reset every five seconds, with a cached queue page. It saved the platform, but because shoppers were really polling at random rather than queueing, some waited 40 minutes for a sale that lasted 40 minutes.
Great: a funnel whose last stage is sized to the stock. Four layers, each cheaper than the one behind it:
- Static first. The page, the countdown and the status object are CDN objects. Pre-warm every edge location before 11:30, and jitter the 10-second poll by ±20% so 5 million timers don’t fire in step.
- A sale pass, checked at the edge. Passes are issued before the sale, after the identity checks in the bot deep dive, and signed with the sale service’s private key. The edge verifies signature, user, sale and validity window (from 12:00:00 by the server’s clock) with the public key — no lookup, no origin call. No pass, or a tap at 11:59:58 from a fast phone, is turned away at the edge.
- A draw instead of a race. Every valid tap between 12:00:00 and 12:00:02 is an entry. The edge gives each entry a number between 0 and 1: an HMAC of the pass ID under a secret the sale controller creates at noon, so nobody can try passes in advance looking for lucky numbers, and tapping again yields the same number — retrying is worthless. Edges count entries; at 12:00:02 the sale controller adds them up (3.6 million) and publishes a threshold of 11,000 ÷ 3,600,000 ≈ 0.0031. Every edge admits the entries below it, with no central list, by handing back a short-lived admission token; the app sends the claim automatically. Everyone else learns at once — “Sold out, you’re about number 48,000 on the waitlist” — worked out from their number and the entry count. Taps after 12:00:02 rank behind every entry, in arrival order.
- Sold out, answered at the edge. When the claim service reports every bucket empty, the controller flips the status to “all claimed — waitlist open” and the edges answer from cache within a second.
The extra 1,000 admissions cover no-shows — apps closed after the tap, phones that lost signal — and if units are still left a few seconds later, the controller raises the threshold. The mechanics of a waiting room — polling a cached counter, signed tokens, randomising early arrivals — are covered in design Ticketmaster. What differs here is that the line is decided by a keyed hash, so the edge needs no queue storage at all, and that admissions are sized to units rather than to server capacity.
The counter now sees about 11,000 attempts instead of 2 million a second, and the bucketed counter’s 320,000 a second becomes the safety margin.
What you pay: a draw is less thrilling than a race, and you must explain it on the page before the sale — though a race at this ratio is a lottery too, one decided by network latency and bot farms. A fair draw still gives an account farm more tickets, which the bot deep dive deals with. And it follows the rule from Amazon’s load-shedding guidance: reject excess work as early and as cheaply as possible, so that the work you accept stays fast — its goodput — instead of everything slowing down together.
How do we turn claims into orders without melting the database?
Ten thousand yeses arrive within about a second. Each needs an order row, price and fraud checks and later a payment intent — work for the store’s order pipeline, which the online store sizes at about 1,200 orders a second at a sale peak.
Bad: create the order inside the claim request. That asks the pipeline for 10,000 orders a second — more than eight times its peak — so claim latency climbs from milliseconds to seconds. Apps time out and retry, retries create duplicates, and the store’s ordinary orders wait behind the sale’s.
Good: claim now, order later, through a queue. When the script says CLAIMED, the claim service publishes a ClaimCreated event — claim ID, user, bucket, pay-by time — to a Kafka topic, waits for the brokers to acknowledge it (acks=all), and only then tells the shopper “You got it — pay by 12:10.” Order workers consume the topic at a fixed 1,000 orders a second, so the 10,000 drain in 10 seconds. They are idempotent consumers: the order ID is derived from the claim ID and inserts ignore duplicates, so a redelivered event changes nothing. The shopper sees “preparing your order…” for at most ten seconds.
Two holes remain. The first is the gap between two writes: the script succeeds, then the publish fails or the claim service crashes. The claim now sits in Redis with no event. If the shopper retries with the same idempotency key, the script answers ALREADY_CLAIMED with the original claim ID and the claim service publishes again — harmless, because everything downstream is keyed by claim ID. If they never retry, the sweeper finds a deadline with no order behind it and releases the unit.
The second hole is that Redis can forget.
Great: Redis is a fast filter; the database is the authority. Redis replicates asynchronously, and the Redis Cluster specification is explicit that a write acknowledged by a primary that dies before replicating it is lost when a replica is promoted. Persistence doesn’t close the gap: an append-only file fsynced every second (the default) can lose about a second of writes, and only helps when the same node restarts. After such a failover, bucket 3’s counter is higher than the truth, so it could say yes to units that winners — whose claims are safely in Kafka — already hold. Three defences, cheapest first:
-
WAITbefore saying yes. After a successful script, the claim service sendsWAIT 1 10: wait up to 10 ms until one replica has the write. Only winners pay it — 10,000 calls in the whole sale, about a millisecond each. Redis’s documentation calls it best effort: it shrinks the window but doesn’t close it. -
A hard cap in the database. The order worker’s transaction counts each order against its bucket and refuses to pass the bucket’s capacity:
BEGIN; INSERT INTO sale_orders (order_id, claim_id, sale_id, user_id, bucket, amount_paise, status, pay_by) VALUES ('ord_9f2c', 'cl_9f2c', 'fs1111', 'u_42', 3, 999900, 'PENDING_PAYMENT', '2026-11-11 12:10:00+05:30') ON CONFLICT DO NOTHING; -- unique (claim_id) and unique (sale_id, user_id) -- 0 rows: a redelivered event, or a second claim by the same shopper. Stop. UPDATE sale_buckets SET allotted = allotted + 1 WHERE sale_id = 'fs1111' AND bucket = 3 AND allotted < capacity; -- 0 rows: Redis said yes to a unit that does not exist. ROLLBACK, void the claim. INSERT INTO outbox (event_type, aggregate_id) VALUES ('OrderCreated', 'ord_9f2c'); COMMIT;This makes overselling impossible whatever Redis does. It is a hot row again, but a gentle one: 1,000 orders a second over 8 bucket rows is 125 updates a second per row, a quarter of the 500-a-second ceiling. A voided claim gets an honest message — “we couldn’t confirm your unit” — before any money moves. A second claim by the same shopper is voided the same way, and its unit returned.
-
Freeze and recompute after a failover. When a bucket’s primary changes, the claim service stops sending to that bucket, sets its counter to 1,250 minus the claims recorded for it in Kafka and the database that haven’t been released, and reopens it — a few seconds of “try again” for one bucket in eight. After the sale, a reconciliation job checks that every claim ended in exactly one place: paid, expired or voided.
What you pay: three systems hold a version of the truth, so you need the reconciler and its alerts, and after a mid-sale failover a few shoppers may see a yes become a no before they pay. The transactional outbox row carries OrderCreated on to payment and notifications without a dual write.
How do we return unpaid units without overselling or stranding them?
If 15% of winners don’t pay, 1,500 units must come back: reliably, soon after each deadline, exactly once, never while someone is paying for them, and to someone who still wants them.
Bad: a TTL on a Redis key. Expiry deletes a claim key, but deleting it doesn’t put the unit back, cancel the order or tell anyone. Redis’s key-space notifications look like the fix, but they are pub/sub messages: a listener that is restarting at 12:10 simply misses them.
Good: a sweeper over the orders table. The high-level design’s sweeper, pointed at sale_orders: every 30 seconds, move orders still pending payment past their pay-by time to expired with a conditional update, decrement allotted, and add the units back to Redis. It is correct, an index on (status, pay_by) keeps it cheap, and units return within 30 seconds of their deadline.
Great: a deadline index next to the stock, the database as referee, and a waitlist.
- Find due claims cheaply. The claim script already wrote each claim’s deadline into its bucket’s sorted set. Once a second, the sweeper asks each bucket for claims that are due:
ZRANGEBYSCORE deadlines -inf <now> LIMIT 0 500. - Let the database decide. For each one,
UPDATE sale_orders SET status = 'EXPIRED' WHERE claim_id = … AND status = 'PENDING_PAYMENT' AND pay_by < now(), withallotted − 1in the same transaction. Then a release script on the bucket removes the claim from the sorted set and, only if that removal succeeded, increments the stock. The guard makes release exactly once: two sweepers racing, or a sweep that crashed between the database and Redis and is retried, return one unit, not two. If the order is being paid, move its deadline to the payment’s own expiry; if it is paid, drop the entry; if there is no order at all because its event was never published, release the unit anyway. - Protect the shopper who is paying. Tapping Pay moves the order from pending payment to
PAYINGwith a conditional update that only works before pay-by. The sweeper never expiresPAYING; it ends when the payment intent expires — 15 minutes in the payment system — or returns to pending payment on a decline if time remains. A payment that succeeds after the order expired is refunded automatically, never revived. - Give units to the waitlist, not the open pool. At 12:10, 3.59 million people have been told “sold out” and stopped watching; the only clients still hammering are scripts. Returning units to the open counter hands them to bots. Instead the controller raises the draw threshold to admit the next 1,500 numbers on the waitlist, sends each a push notification, and gives them five minutes to pay.
There is a twist in the timing. Nearly every claim was made between 12:00:00 and 12:00:02, so nearly every deadline falls between 12:10:00 and 12:10:02. All 1,500 units come back within two seconds: a second, smaller flash sale ten minutes after the first, handled by the same machinery. If 70% of waitlisted shoppers pay, 1,500 × 0.7 = 1,050 sell by 12:15 and 450 return; then 315 sell by 12:20 and 135 return; then about 95 by 12:25. Four rounds in 25 minutes leave about 40 units, 0.4% of the stock.
How do we stop bots and enforce one per customer?
A phone selling at ₹9,999 that resells for ₹15,000 is worth ₹5,000 to a reseller per unit, so expect patient, well-funded automation. The draw has already removed the speed advantage. What remains is quantity: every account is a ticket.
Bad: a CAPTCHA on the Buy button. It adds a step for 4 million people in the two seconds that matter, solving services pass it for a fraction of a rupee, and it checks nothing about who the shopper is.
Good: one per account, enforced twice. The Redis winners hash stops a second claim in milliseconds; the database’s unique constraint on (sale_id, user_id) is the guarantee. Add rate limits per account, device and IP on pass and claim calls. Each account now does little damage — but a farm of 10,000 accounts still gets 10,000 entries in the draw.
Great: make identity expensive before the sale, and cheap to check during it. Spend the checks where there is time to think, and leave noon with nothing but signature checks:
| Check | When | What it stops | What honest shoppers pay |
|---|---|---|---|
| Register with a verified phone number; require some account age or a past order | Days before | Fresh throwaway accounts | An extra step; brand-new customers may be left out |
| A CAPTCHA or proof-of-work when the pass is issued | Before 11:30 | Scripted bulk registration | A puzzle, but on a calm afternoon |
| Pass bound to account and device; one pass per phone number | At the edge, at noon | Shared or resold passes | Switching phones mid-sale fails |
| One unit per delivery address and per card or UPI ID | At order creation and payment | Account rings shipping to one flat or paying with one card | A family sharing one card gets one phone |
| Keyed random draw | At noon | Speed and retry advantage | Nothing |
| Review after the sale | Within hours | Rings that slipped through; cancelled units go to the waitlist | Occasional false positives, so keep an appeal path |
Normalise addresses before comparing them (“Flat 4B, MG Road” and “4-B, M.G. Rd.” are one address), compare instruments as hashes, and keep the matching rules private — publishing them teaches rings what to vary.
How do we keep the sale from taking down the rest of the store?
A perfect counter is still a failure if the sale drags down festive shopping for everyone else — as when one merchant’s 2016 flash sale took down every store on its Shopify database shard.
Bad: run the sale through the store’s normal path. Same product service, same checkout, same inventory database. The sale’s hot row fills the shared connection pool, and checkout for everyone slows down with it.
Good: separate pools — bulkheads. A dedicated claim service, Redis and Kafka topic for the sale, and separate connection and thread pools wherever it touches a shared service such as payments (see bulkheads). Tag sale traffic at the edge so that it is shed before ordinary store traffic.
Great: a sale cell with one narrow, metered bridge. Route the sale’s paths at the CDN to their own cell: edge rules, sale controller, claim service, Redis, Kafka partitions and order workers. The cell shares nothing on the request path with the store. It reaches the store at two points, both metered: order workers capped at 1,000 orders a second, and the payment service through its own PSP quota.
Then make the cell boring on the day:
- Pre-scale and break it first. Provision for two to three times the forecast, and load-test far past the breaking point, as Amazon advises, not just to the target. Rehearse the bad day: kill a Redis primary at 12:00:01 in a test sale.
- Switches, not deploys. Flags to pause the sale, extend pay-by windows, force “sold out” or go waitlist-only. A kill switch beats a hotfix at 12:00:30.
- Fail closed. If the controller or the draw is unhealthy, the sale pauses; it never opens the gates to the full crowd.
What you pay: infrastructure that idles most of the year — so run one cell for all the day’s flash deals — and a second deployment to keep in step.
The final design
Everything that says no is at the edge, everything that says yes is one script on one bucket, and everything that makes the yes real is behind a queue with the database as referee.
- Register. In the days before the sale, shoppers register; the passes service runs the identity checks and issues each a signed sale pass valid from 12:00 to 12:15.
- Wait. The page, countdown and status object come from the CDN, corrected by the server’s clock. Taps between 12:00:00 and 12:00:02 become draw entries, each with a keyed random number computed at the edge.
- Draw. The sale controller counts entries and publishes a threshold that admits about 11,000; the edge gives them admission tokens and everyone else a waitlist position. When every bucket is empty, the controller publishes “all claimed” and the edge answers from cache.
- Claim. The gateway checks the token’s signature; the claim service runs the Lua script on the shopper’s home bucket — one of eight Redis primaries — and waits for one replica.
- Record. The claim service publishes
ClaimCreatedto Kafka and, once the brokers acknowledge it, tells the shopper “pay by 12:10”. - Order. Order workers consume at 1,000 a second and, in one transaction, insert the order, check the bucket’s cap and write an outbox event.
- Pay. The shopper pays through the payment service and the PSP; the webhook’s outcome flows back as an event on Kafka, and the order becomes paid.
- Release. Every second the sweeper reads due deadlines from Redis, expires unpaid orders in the database, returns their units to their buckets and lets the controller admit the next shoppers on the waitlist.
Check it against the requirements. Overselling is impossible because the database caps each bucket, whatever Redis says. Unpaid units return within about a second of their deadline, to waitlisted people rather than scripts. The 2 million requests a second meet the CDN and the edge; the counter sees about 11,000 attempts and could take 320,000 a second. Every “no” comes from the edge in milliseconds, and a “yes” costs one script, one WAIT and one Kafka write — well inside 500 ms. The store shares nothing on the request path and sees at most 1,000 extra orders a second. One account gets one entry, one pass and one unit.
What interviewers look for by level
| Area | Mid-level | Senior | Staff |
|---|---|---|---|
| Requirements | “Don’t oversell” and a countdown | Puts numbers on the crowd-to-stock ratio, latency even for a no, and isolation from the store | Asks whether a draw is allowed; defines undersell and fairness as requirements, not afterthoughts |
| Stock counter | A conditional UPDATE |
Spots the hot row and computes its ceiling; moves to an atomic Lua script in Redis | Bucketed stock routed by user, rebalancing, tokens for exact reconciliation |
| Absorbing the crowd | Autoscaling and a rate limit | CDN-cached page and status, signed passes, a sold-out flag at the edge | Admissions sized to stock, a stateless keyed draw, goodput and cheap rejection |
| Orders | Creates the order in the claim request | Queue, idempotent workers, a paced write rate | Redis as filter and the database as authority: WAIT, a hard cap, freeze and reconcile after failover |
| Expiry | “Use a TTL” | Sweeper with conditional state changes; protects payments in flight | Deadline index beside the stock, exactly-once release, the 12:10 cliff, waitlist rounds and undersell maths |
| Bots and fairness | CAPTCHA | One per account, rate limits per device and IP | Identity checks moved before the sale; limits per phone, address and instrument; honest trade-offs about friction |
| Operations | Not discussed | Pre-scaling and load tests | A sale cell with metered bridges, kill switches, failover drills and fail-closed admission |
Follow-up questions
What if fifty deals open at noon, not one?
Each deal gets buckets sized to its expected attempt rate — one for a 500-unit deal, eight for the phone — with hash tags spread over a shared cluster of, say, 24 primaries in the sale cell. Draws are per deal, each with its own secret and threshold. Fifty deals of 1,000 units send about 55,000 attempts to the counter in total, which is trivial. The shared resource is the 1,000-a-second order budget, so split it between deals in proportion to their units.
What if a Redis primary fails in the middle of the sale?
Claims already answered “yes” are safe: they reached Kafka before the shopper heard back. The claim service sees the bucket’s primary change, stops routing to it, and once the cluster has promoted a replica, sets that bucket’s counter from the claims recorded in Kafka and the database before reopening it. Shoppers whose home bucket is frozen are tried on another bucket. If the promoted replica had missed a few acknowledged writes, the database cap voids any extra yeses before payment. Seven buckets of eight never notice.
What if the business insists on first come, first served?
Then run a race, honestly. The edge rate-limits to what the bucketed counter can take — 200,000 a second here — and “first” means when the edge received the request, never a timestamp the client sends. Passes, one per customer and the database cap all still apply. Say plainly what you give up: at 400 people per unit, network distance and automation pick the winners. A middle path is a short draw window, such as 200 ms, which feels like a race but stops rewarding shaved milliseconds.
What if a shopper may buy up to two units?
The script takes a quantity: it checks that the bucket has at least that many and that the shopper’s count in the winners hash plus the request stays within two, then decrements by the quantity. The database cap becomes allotted + 2 <= capacity. Decide whether to sell one when only one is left — most sales do, and say so. The draw then admits remaining units divided by the average quantity per shopper, about 1.5 if half buy two.
What if the sale runs in two regions?
Keep the edge everywhere and decide where the stock lives. The simplest answer is a single home region for the counter: claims from the other region pay tens of milliseconds more, which is fine for about 11,000 attempts. To survive losing a region, split the buckets — four in each — with each region’s edge sending to its own buckets, and move leftovers across at the end. If a region fails, its buckets freeze until reconciliation, and the other region keeps selling its half. Never let both regions write the same bucket; that is how you oversell. More in multi-region and active-active.
How to say it in the interview
“I’ll design a flash sale: 10,000 phones at ₹9,999 at noon, 5 million people waiting, one per customer, ten minutes to pay. Never oversell, don’t strand stock, and don’t take the store down. The key ratio is 400 people per unit, so the system’s main job is saying no cheaply.
The page, countdown and status object are on the CDN, using the server’s clock. Shoppers register beforehand and get a signed sale pass valid from noon. At noon, every tap in the first two seconds becomes a draw entry: the edge computes a keyed random number per pass, a controller publishes a threshold that admits about 11,000, and everyone else gets a waitlist position from the edge. Retrying doesn’t change your number, so speed and scripts don’t help.
Stock is in Redis, split into eight buckets of 1,250 on eight primaries. A shopper always lands on the same bucket, so one Lua script checks they haven’t claimed, decrements only if stock is positive, and records a pay-by deadline, atomically. One SQL row caps out near 500 claims a second because of commit time, one Redis key near 40,000, eight buckets near 320,000 — my safety margin if the draw misbehaves.
After the script, I wait for one replica, publish ClaimCreated to Kafka and only then say ‘you got it’. Order workers create orders at 1,000 a second with a per-bucket cap in the database, so even if Redis loses writes in a failover, the database won’t oversell.
A sweeper expires unpaid orders every second and gives their units to the waitlist, not the open pool. Bots face phone, address and instrument limits checked before the sale. It all runs in its own cell, with kill switches, and fails closed.”
Concepts used in this design
- Contention and double booking — the general pattern behind one counter and many buyers.
- Lost update problem — why read-then-write oversells.
- Optimistic vs pessimistic locking — what a conditional update and a row lock really cost.
- Redis — atomic scripts, sorted sets for deadlines,
WAIT, and asynchronous replication. - Scaling writes — splitting one hot counter into buckets.
- Hot keys, cache stampede and invalidation — why one key on one thread is the ceiling.
- Content delivery network — serving the page, the status and every “no” from the edge.
- Backpressure and load shedding — rejecting early and cheaply to protect goodput.
- Rate limiting — the race-mode gate and per-account limits.
- Kafka — the durable record of each claim and the buffer in front of the order pipeline.
- Idempotent consumers and the inbox pattern — order workers that tolerate redelivery.
- Transactional outbox — handing order events on without dual writes.
- Database constraints — the unique keys and cap that make the database the referee.
- Idempotency — retried claims that return the same answer.
- Cell-based architecture — keeping the sale’s blast radius away from the store.
- CAP theorem and PACELC — choosing to refuse rather than oversell.
References and further reading
- Redis DECR commandredis.ioAn O(1) atomic decrement that returns the new value, limited to 64-bit signed integers; a missing key counts as 0.
- Scripting with Lua (Redis documentation)redis.ioA script runs atomically and blocks all other server activity while it runs; every key it touches must be passed as a key argument so it works in a cluster.
- Redis benchmark (Redis documentation)redis.ioCommand execution is mostly single-threaded per instance; the documented example runs about 180,000 SETs a second against a single key without pipelining; scale across cores with more instances.
- Redis persistenceredis.ioAOF with fsync every second (the default) can lose about a second of writes; fsync always is very slow but group-commits; RDB snapshots can lose minutes.
- Redis WAIT commandredis.ioBlocks until a number of replicas acknowledge earlier writes or a timeout passes; improves safety on failover but is best effort and does not make Redis strongly consistent.
- Redis Cluster specificationredis.ioReplication is asynchronous, so a write acknowledged by a primary that dies before replicating it is lost when a replica is promoted; hash tags and slots.
- PostgreSQL documentation: Transaction isolationpostgresql.orgUnder read committed, an UPDATE that finds its row being changed waits for the other transaction, then re-evaluates its WHERE clause against the new row version.
- Alibaba Cloud ApsaraDB RDS for MySQL: Inventory hintalibabacloud.comAn engine feature for flash-sale stock decrements on one hot row (queuing plus commit-on-success hints), rated at up to 31,000 single-row updates a second; the linked test used a 90-core dedicated host.
- Surviving flashes of high-write traffic using scriptable load balancers, part I (Shopify Engineering)shopify.engineeringA 2016 flash sale took down every store on the merchant’s database shard; the fix was a leaky-bucket checkout throttle at the Nginx/Lua edge with a 5-second period, a cached queue page and a signed cookie — and random polling made some customers wait 40 minutes for a 40-minute sale.
- Using load shedding to avoid overload (Amazon Builders’ Library)aws.amazon.comGoodput versus throughput, rejecting excess work cheaply and as early as possible, and load-testing far beyond the point where a service breaks.