What the interviewer is asking
"Design a flash sale" sounds like a small shop: one product, a low price, a start time. The hard part is the traffic. At 12:00 sharp, about 1 million people tap Buy within seconds, all for the same 1,000 units, so nearly all of the day's load lands on one number: the stock count.
Two rules make it a design problem: never sell more than the stock, and answer everyone fast, though 999 in 1,000 of those answers are "sold out". The walkthrough follows the 8 steps of the interview framework: scope, data, API, the busy path, numbers, services, scale and deep dives.
1. Scope: what version 1 must do
- Sell 1,000 units of one product, starting at 12:00.
- Never oversell, and one per customer.
- A fast answer for everyone: "you got one, pay within 10 minutes" or "sold out".
Park the rest out loud: many products at once, carts, recommendations.
2. Data: one count, one order per customer
record fields
sale id, product_id, price, starts_at, stock
order sale_id, customer_id, status, held_until, payment_id
unique (sale_id, customer_id)
The units are identical, so what's left is one number: the stock on the sale's row, starting at 1,000. A row per unit only adds work, since nobody picks unit 417; rows per item are for things people pick, like concert seats. Counting the orders instead is slow, and two buyers can both count 999 and both buy.
The unique (sale_id, customer_id) key makes the database itself refuse a second order from the same customer. The order keeps the payment provider's id, never the card.
3. The API: the page and the buy
GET /sale
→ 200 OK {: 49, : "12:00:00"}
POST /sale/buy {: "k7"}
→ 201 Created {: "o12", : "12:10"}
→ 409 Conflict {: "sold_out"}
The idempotency key (the Idempotency Keys lesson) makes a retried tap get its first reply back, not a second unit. "Sold out" is a final answer. A 503 "try again later" would tell a million apps and scripts to retry, and holding requests open until a unit comes back would leave nearly a million connections waiting.
4. The busy path: the tap at 12:00:00
- Ana's app sends
POST /sale/buywith her key. - The server lowers the stock by 1, only if it's above 0.
- It saves her order, held for her until 12:10.
- It answers: you got one, pay by 12:10.
- Ana pays, and the order is marked paid.
In version 1, one conditional update of one row decides every tap (the Concurrency and Locks lesson), and all 1 million buyers take this path in the same few seconds.
5. The numbers: ten seconds, one row
From the interviewer: about 1 million buyers, nearly all tapping in the first 10 seconds. Assume one database row takes about 5,000 updates a second, since updates to one row take turns (the Distributed Counter lesson).
taps 1 million ÷ 10 s = 100,000 taps a second
per unit 1 million ÷ 1,000 = 1,000 buyers for every unit
the row 1 million updates ÷ 5,000 a second = 200 s
winners 1,000 updates ÷ 5,000 a second = 0.2 s
The row could serve the winners in 0.2 seconds, but if every tap updates it, the last buyer waits over 3 minutes to hear "sold out".
6. Services: only the decision at the tap
- The sale page is the same for everyone and doesn't change during the sale, so it goes on a CDN (the CDNs lesson). Each device counts down from the start time.
- Sale servers do only what must happen at the tap: decide, and lower the stock.
- After the tap: charging the card, the receipt email, shipping.
7. Scale: most taps can only lose
Sale servers can be added for 100,000 taps a second, but every tap still updates the one stock row. A queue in front of the row absorbs the burst (the Message Queues lesson), but each tap still waits its turn: the last answer still comes after 200 seconds. Splitting the stock into 10 rows of 100 takes about 50,000 updates a second, but all 1 million taps still update a row: 20 seconds, nearly all of it to say "sold out".
The fix starts from the numbers: only 1,000 taps can win. Answer the taps that can't win without touching the row.
8. Deep dive: answering without the database
After the stock is gone. At 100,000 taps a second, the first 1,000 taps arrive in the first 10 milliseconds; every tap after the last unit can only hear "sold out". So set a sold-out flag once, when the stock hits 0, and copy it to every sale server and to the CDN. Many CDNs can run a small rule at the edge, so the edges answer POST /sale/buy themselves. If they pick the flag up within about a second, at most the first second's 100,000 taps reach your servers, and the other 900,000 are answered at the edge. A late flag is harmless: the servers still say "sold out". Hiding the Buy button helps but never decides, since scripts call the API directly.
Before the stock is gone. Before 12:00, give each of the 10 sale servers an allowance of 100 units in its own memory, 1,000 in all (say 10 servers; a real sale may run more). A tap takes one from its server's allowance: a server that gets 20,000 taps in the first second confirms only its 100 and answers the other 19,900 from memory. Each win goes on an order queue, and workers save the orders at the row's pace: 1,000 updates, about 0.2 seconds, though the wins arrive in a burst of milliseconds.
Why not one shared counter in an in-memory store? Many designs use one, but every tap would hit it: 100,000 a second, about all one cache server answers. Allowances spread that over 10 servers, with nothing shared on the busy path.
One server runs out first. Say S1 gets 20 taps a millisecond and S10 only 4, 100 a millisecond in all:
time taps sold told sold out left on S1 / S10
0 ms 0 0 0 100 / 100
5 ms 500 500 0 0 / 80
10 ms 1,000 830 170 0 / 60
25 ms 2,500 1,000 1,500 0 / 0
At 10 ms, S1 is empty while S7 to S10 still hold 170 units. S1 should just answer "sold out": the others' last units go to the taps that reach them, and all 1,000 sell by 25 ms, never more. The price is fairness at the millisecond level: 170 of the first 1,000 taps heard "sold out" while units were left, and taps up to 20 ms later won them. With 1,000 buyers per unit, nobody can tell. Asking other servers for units on every such tap would make servers wait on each other in the busy path; move leftovers once, if units sit unsold for seconds. And never give each server all 1,000: the allowances must add up to the stock, or up to 10,000 buyers hear "you got one".
9. Deep dive: never oversell, one per customer
The last unit. A server handles taps on several threads. If each thread checks the allowance and then lowers it, Ben and Cy can both see S3's last unit: 1,001 sold. As in Concurrency and Locks, the check and the write must be one step, an atomic decrement that refuses below 0: Ben's lowers 1 to 0, and Cy's changes nothing. Checking twice only narrows the gap; locking the stock row per tap brings back the hot row.
The record decides. Workers repeat the check where the record is, in one transaction with the order:
BEGIN
UPDATE sale SET stock = stock - 1 WHERE id = 7 AND stock > 0
INSERT INTO orders (sale_id, customer_id) VALUES (7, 'ana')
COMMIT
So even a bug in the allowances can't sell a 1,001st unit. A server answers a winner once the order is saved, a fraction of a second later.
Two tabs. Ana taps Buy in two tabs, reaching S1 and S5. Each tab made its own idempotency key, and keys catch the same request sent twice, not two requests from one customer; S1 and S5 don't share memory. The unique key catches it: the first order is saved (stock 500 to 499), the second insert fails and its transaction is undone, S5 puts its unit back, and Ana's second tab gets her first order.
10. Deep dive: holds and the second wave
Each winner has 10 minutes to pay: a hold, as in the Ticketmaster lesson. But a seat's row carries its own held_until and frees itself; here the units are one number, lowered for each saved order, and time passing doesn't raise it. A job finds holds that ran out, marks each order expired (only if still held and unpaid, so a payment at 12:09:59 can't also hand its unit back) and adds 1 to the stock, in one transaction.
All the holds start within milliseconds of 12:00, so they end together at 12:10. If 1 in 10 winners never pays, 900 pay and 100 units come back at once: the flag turns off, the units go into the allowances, and a second wave sells them the same way.
11. Deep dive: before the sale
- The page and photos on the CDN.
- All servers up, holding their allowances. Autoscaling is too late: new servers take minutes, the sale takes seconds.
- Warm caches: the product, the price, the flag (the When Caches Go Wrong lesson).
- Limits per account and per IP address (the Rate Limiting lesson), and accounts required: one per customer needs to know the customer.
- The servers' clock decides when 12:00 is, so a fast device clock can't buy early.
What breaks first
- A server crashing mid-sale strands its allowance: move unsold units back and offer them again.
- The payment provider: payments spread over 10 minutes, and retries are safe with idempotency keys.
- The second wave at 12:10 needs the same flag, allowances and limits as the first.
Summary: the order to follow
- Scope: 1,000 units at 12:00, never oversell, one each, fast answers.
- Data: one stock count; orders with a unique (sale, customer) key.
- API:
GET /sale,POST /sale/buywith an idempotency key; 409 "sold out". - Busy path: the tap at 12:00:00.
- Numbers: 100,000 taps a second; one row takes 5,000: 200 seconds.
- Services: the page on a CDN; only the decision at the tap.
- Scale: answer the taps that can't win without the row.
- Deep dives: a sold-out flag at the edge and 100 units in each server; one atomic step and a unique key; holds released into a second wave; everything ready before 12:00.
The same pattern fits any sale of identical items with far more buyers than stock: find the few requests that can succeed, answer the rest cheaply, and let one exact record decide.