Design flash-sale checkout
At exactly 12:00, 10,000 limited-edition sneakers go on sale and 200,000 people press "Buy" within two seconds. For the rest of the day the shop is quiet. The design has to survive a crowd twenty times larger than the stock, sell every pair exactly once, keep bots from buying everything, and keep the rest of the shop working while it happens. This page has material for a full 60–90 minute interview.
1. The prompt
"An online shop runs flash sales: a limited quantity of a product at a special price, starting at a set time. Design the checkout so it works when far more people show up than there is stock."
Design D15 (ticket booking) covered races on individual seats and a waiting room. A flash sale differs in three ways:
- One counter: identical units share a single stock counter, which becomes the hottest row in the company.
- The crowd: it all arrives in one second, not over minutes.
- The rest of the shop: it must keep serving its normal traffic at the same time.
2. Frame the problem
How much stock, and how many buyers?
About 10,000 units; up to 200,000 buyers in the first two seconds, plus bots.
The crowd is 20× the stock, so most people will be told "sold out". Telling them quickly and cheaply is half the design.
Is it one product, or several sizes and colours?
One product in a few sizes (say 8), each with its own stock.
Each size is its own counter; the hottest size decides contention.
Limit per customer?
One pair per account.
The limit must be enforced atomically with the reservation, and accounts must be hard to fake.
Does a buyer get time to pay?
Yes, 10 minutes to complete payment after securing a pair.
Stock is reserved first and sold on payment; unpaid holds return to stock.
Is overselling ever acceptable?
No. Telling someone their order is cancelled after paying is the worst outcome.
The stock decrement must be atomic and happen before payment.
Should the earliest click win?
Roughly. Everyone who is present at 12:00 should have a fair chance, and bots shouldn't win just by being faster.
A waiting room that randomises people who arrive before the start is fairer than "fastest request wins".
What else runs on the shop at the same time?
The whole normal catalogue and checkout.
The sale path must be isolated so it can't take down everyday orders.
3. What it must do
- A buyer can try to secure one unit when the sale opens, and is told quickly whether they got one.
- A buyer who secured a unit can pay within the hold time; then the order is confirmed.
- Everyone sees the sale's state: countdown, open, their place in line, sold out.
Not now:
- Raffles (enter, then winners are drawn): a different, easier design. Mention it as a fairness alternative.
- Dynamic pricing: a separate product decision.
- Shipping and fulfilment: ordinary order processing after the sale.
- Resale prevention: a policy problem, not a checkout one.
4. How well it must do it
| Target | Why this number |
|---|---|
| Never oversell: units sold ≤ stock, always consistency | Cancelling paid orders destroys trust, and often breaks consumer-protection rules. |
| Fast answer: every buyer learns "you got one", "you're in line" or "sold out" within 2 s, even at the peak speed · load shape | A spinning button makes people refresh, which multiplies the load. |
| Sell it all: every unit ends up with a paying buyer; abandoned holds go to the next person within seconds of expiring data safety | Unsold stock after a sell-out is lost revenue and an angry waitlist. |
| Fair: one unit per person, and bots get no advantage over people security · rules | Flash sales are a target for scalper bots; fairness is part of the brand. |
| Isolated: the rest of the shop keeps its normal latency during the sale surviving failures | A sale that takes down everyday checkout costs more than the sale earns. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Peak arrivals | 200,000 buyers in 2 s, each retrying a few times | ~100,000–300,000 requests a second for one product → so most must be answered at the edge, without reaching our servers |
| Hot row | A database row update under a row lock takes ~1–2 ms, one at a time | A single stock row tops out around 500–1,000 changes a second → so one row can't be the counter |
| Actual winners | 10,000 units | Only 10,000 reservations need a database transaction → so admit work at the rate of real winners, and answer the other 190,000 with a cached "sold out" |
| Payments | 10,000 payments over ~10 minutes | ~20 a second: easy for the payment provider, if we don't send them all in the first second |
Skip product-page traffic numbers: the page is static and served by the CDN. Saying so is enough.
6. The nouns
- Sale: product, start time, per-customer limit, status.
- Stock: what's left, per size.
- Hold: a reservation of one unit for one buyer, with an expiry.
- Order.
- Payment.
- Waitlist entry.
sales sale_id, sku, starts_at, limit_per_user, status (scheduled | open | sold_out | closed)
units sale_id, size, unit_no, status (free | held | sold), hold_id (one row per unit, see 9.1)
holds hold_id, sale_id, user_id, unit, expires_at, status (active | paid | expired)
UNIQUE (sale_id, user_id) (one per customer, enforced by the database)
orders order_id, hold_id, user_id, amount, payment_id, status
7. The contract
REST, with the user from the session token. The "buy" call is deliberately small: it only secures a hold; payment is a separate call.
GET /v1/sales/{sale_id} → {status, starts_at, server_time, sizes: [{size, available: true|false}]} (cached at the CDN for 1 s)
POST /v1/sales/{sale_id}/holds {size} Idempotency-Key: … (needs an admission token from the waiting room)
→ 201 {hold_id, expires_at} | 409 {status: "sold_out", waitlist: true} | 429 {retry_after}
POST /v1/holds/{hold_id}/pay {payment_token} Idempotency-Key: … → {order_id, status: "confirmed"}
DELETE /v1/holds/{hold_id} → release early
POST /v1/sales/{sale_id}/waitlist {size} → join the queue for released units
server_time lets the countdown use the server's clock, not the phone's, so nobody's button unlocks early. "Available" is a yes/no flag, not an exact count, which keeps the response cacheable.
8. First working version
One app tier, one relational database, the payment provider, and a CDN for the product page.
8.1 Securing a unit
One transaction: decrement the size's stock row only if it's above zero, and insert the hold. The unique key on (sale_id, user_id) enforces "one per customer" even if someone sends ten requests at once.
BEGIN;
UPDATE stock SET available = available - 1 WHERE sale_id = $1 AND size = $2 AND available > 0; -- 0 rows → sold out
INSERT INTO holds (hold_id, sale_id, user_id, size, expires_at) VALUES (…, now() + interval '10 minutes');
COMMIT;
8.2 Paying
The buyer pays through the payment provider with an idempotency key (Design D16). On success, one transaction marks the hold paid and creates the order. A sweeper job returns expired holds to stock every few seconds.
8.3 Showing the sale's state
The product page is static HTML on the CDN. A small status endpoint (open, sold out per size) is cached for one second, so 200,000 people polling it cost the origin about one request a second.
Parked for later (section 9 fixes each)
- Every buy request queues on one row lock; most time out before reaching it.
- 200,000 requests hit the app servers at once, mostly for people who can't win.
- Holds released by the sweeper go back to a stock nobody is watching any more.
- Bots with thousands of accounts take most of the stock.
- The sale shares databases and servers with the everyday shop.
- Payments, emails and order processing all start in the same second.
9. Make it hold up
9.1 The hot stock counter never oversell · fast answer
| Approach | Why | |
|---|---|---|
| ✗ Bad | One stock row per size, updated with available - 1 under a row lock | Correct, but every buyer waits in line for the same lock: a few hundred a second. The lab shows most buyers giving up while stock is still left. |
| ~ Good | An atomic counter in Redis (a DECR, or a Lua script that checks the per-user limit and decrements together) | Tens of thousands of operations a second on one key, and Redis runs a script atomically. But the counter lives outside the orders database: a crash between the decrement and writing the hold leaks a unit, so you need reconciliation. |
| ✓ Great | One row per unit, claimed with SELECT … FOR UPDATE SKIP LOCKED LIMIT 1 in the same transaction that writes the hold (and the stock ledger) | Thousands of buyers claim different rows in parallel instead of queuing for one, and stock and holds are always consistent. Shopify described moving its inventory reservations from Redis to MySQL this way, with a row per sellable unit, capped at 1,000 rows per item and location. |
Whichever counter you choose, publish a sold-out flag per size the moment stock hits zero, and cache it at the edge. From then on, every other buyer gets "sold out" from the CDN in milliseconds, without touching the counter. Splitting stock into several sub-counters (say 10 buckets of 1,000) is another way to spread the load; the catch is a bucket that empties early while others still have stock, so a miss must try another bucket.
9.2 Reserve first, pay second never oversell · sell it all
- Order matters: take the unit before payment. If payment comes first, everyone who saw "available" in the first seconds pays, and thousands of payments must then be refunded. The lab shows the size of that mistake.
- Holds expire: each hold carries
expires_at. The buy query can treat expired holds as free, so correctness doesn't depend on the sweeper (the same trick as Design D15). The sweeper tidies rows and triggers the waitlist. - The waitlist gets released units: when a hold expires or a payment fails, the unit goes to the next person on the waitlist, who gets a short hold of their own and a notification. Without this, units come back after the crowd has left and stay unsold.
- Payments are idempotent: the hold id is the idempotency key's root, so a retried payment never charges twice (Design D16).
9.3 Absorbing the crowd at the edge fast answer · isolated
| Approach | Why | |
|---|---|---|
| ✗ Bad | Let every click reach the app servers and autoscale | Autoscaling takes minutes; the spike lasts seconds. Servers saturate, and timeouts make people retry. |
| ~ Good | Rate-limit at the load balancer and return "try again" | Protects the servers, but retries add more load, and the fastest clicker (often a bot) wins. |
| ✓ Great | A waiting room at the edge: arrivals get a queue page served from the CDN; people present before the start are put in random order; they're admitted at the rate the hold service can handle, each with a short-lived signed token; once sold out, everyone still waiting is told at once and offered the waitlist | The origin sees a steady, chosen rate. People see a position, not an error, so they don't hammer refresh. Shopify has described the same pattern: a leaky-bucket throttle at its load balancer, a queue page cached in Nginx, and a signed cookie that lets admitted buyers through. |
Also prepare for 12:00: pre-warm caches and connection pools, pre-scale the hold service, and make the static page and assets cacheable so the moment the button unlocks costs the origin nothing. The countdown uses server_time, so a phone with a fast clock can't jump the queue.
9.4 Bots and fairness fair
- One per customer, enforced by the database: the unique key on
(sale_id, user_id)holds no matter how many requests or tabs a person opens. - Make accounts expensive: require accounts created before the sale was announced, verified payment methods or phone numbers, and a purchase history for very hot drops.
- Challenge at the door, not at the counter: bot checks (device signals, behavioural checks, a challenge for suspicious sessions) run while people wait in the waiting room, so they don't slow the hold step.
- Randomise the start: everyone in the room at 12:00 gets a random position, so speed alone doesn't win.
- Bind tokens to sessions: admission tokens are signed, expire quickly and are tied to the session and device, so they can't be shared or sold.
- Post-sale checks: cancel orders that share shipping addresses or cards across many accounts before they ship.
9.5 Keeping the rest of the shop alive isolated
- Bulkheads: the sale runs on its own hold service, its own database (or at least its own connection pool) and its own queues, so its load can't starve everyday checkout (Module 24).
- Turn things off: during the sale, disable expensive extras on the product page (recommendations, live review counts) with feature flags.
- Watch your partners' limits: payment providers and fraud-check services have rate limits too. Since payments are spread over the 10-minute hold window, keep a client-side limiter in front of them.
- Kill switch: a single flag pauses the sale (for a price mistake or an attack) and shows a holding page from the CDN.
9.6 Orders and payments after the rush sell it all · never oversell
Once a hold is paid, the rest is ordinary order processing, but there is a lot of it in a short time. The payment confirmation writes the order and an outbox event in one transaction (Module 22). Workers take it from there: confirmation email, warehouse allocation, invoicing. Each step is idempotent and retryable. A reconciliation job after the sale checks that units sold = paid orders = payment provider captures, and refunds anything that doesn't match.
9.7 The whole design after the deep dives
One buyer, from the countdown to a confirmed order, through every part.
10. Interviewer follow-ups
Ten times the buyers: 2M in the first minute.
The edge absorbs it: the queue page and the sold-out flag are CDN responses, and admission stays at the rate of the hold service. The only thing that grows is the waiting room's own capacity (a managed edge product or our edge layer) and the number of "sold out" answers, which are cached. The hold path does the same 10,000 transactions either way.
Stock sits in three warehouses.
Keep unit rows per (sale, size, location). The claim picks a location by rule (nearest to the buyer, or most stock) and falls back to others with the same SKIP LOCKED query. Shopify's article describes the move to multi-location reservations for this reason.
Redis (or the hold database) fails halfway through the sale.
Pause admissions at the waiting room immediately (people stay in line; nobody sees errors). With unit rows in a replicated database, fail over and resume; holds are durable. With a Redis counter, rebuild it from the database (stock minus active holds) before resuming. That's exactly why keeping stock and holds in one transactional store is attractive.
A buyer opens five tabs and clicks Buy in all of them.
All five requests carry the same user; the unique (sale_id, user_id) key lets one insert succeed and the others fail, inside the same transaction as the unit claim, so the extra claims roll back. The client also sends an idempotency key per click.
The payment provider starts declining or slowing down.
Holds keep their 10 minutes, so slow payments are fine. Retry declines due to provider errors with backoff; show a clear message for real declines. If the provider is down, extend all active holds by the outage length (a single update) rather than letting them expire.
The price was entered wrong: $9 instead of $90.
Hit the kill switch: the sale pauses at the edge. Unpaid holds are released; paid orders follow the shop's policy for pricing errors. Afterwards, add a two-person approval for sale configuration and a sanity check on discounts.
How do you test this before the real sale?
Load-test with synthetic buyers at more than the expected peak against a production-like environment, including the edge and the payment sandbox. Run a game day that kills the hold database mid-test. Do a small real "soft launch" sale first, and rehearse the kill switch.
Push notifications at 11:59 wake up a million phones at once.
That's a self-made thundering herd. Spread the reminders over the preceding minutes, point them at the static waiting-room page (cached), and let the room decide admission order. Don't send everyone straight to the hold endpoint.
Why not run it as a raffle instead?
A raffle collects entries over hours, draws winners at random, then gives each a payment window. It removes the spike entirely and is the fairest against bots, at the cost of instant gratification. Mention it as the product alternative; many hyped launches use it.
The CEO wants a live "units left" counter on the page.
Publish an approximate count, updated every second from the stock service and cached at the edge (with a CDN cache time of one second, the origin sees one request a second). Exact live counts per viewer would mean 200,000 reads a second of the hottest row.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Hold database slows | Longer wait in the room | Hold latency; admission backlog | Admission rate drops automatically; nobody gets errors |
| Waiting room misconfigured (admits too fast) | Timeouts on Buy | Hold error rate; origin request rate | Lower the admission rate live; bulkheads protect the rest of the shop |
| Payment provider outage | "Payment delayed" with the hold kept | Payment errors and latency | Extend holds; retry with backoff and idempotency keys |
| Sweeper stops | Released units appear late | Expired-but-active holds count | Buy query treats expired holds as free; waitlist worker restarts |
| Bot wave | Real people lose out | Accounts per address or card; challenge failure rate | Challenges in the room; one-per-account key; post-sale cancellations |
| Wrong price or stock | Sale paused | Alerts on discount and stock sanity; sales-rate anomaly | Kill switch at the edge; config approval |
12. Wrap-up and the clock
Trade-offs we made:
- A waiting room (slower for some, fair for all) instead of a free-for-all.
- Unit rows with
SKIP LOCKEDin the transactional store, rather than a faster but separate counter. - Reserve before payment, at the cost of handling expiries.
- A yes/no availability flag instead of exact live counts.
- Bulkheads, at the cost of extra infrastructure.
What we'd watch:
- Hold latency and error rate.
- Admission rate against hold capacity.
- Units held, paid and expired.
- Waitlist conversions.
- Origin requests per second.
- Everyday checkout latency (the bulkhead check).
- Payment errors.
At 10×:
- More edge capacity.
- Raffles for the hottest drops.
- Per-location stock.
- Stricter account rules.
A 60-minute interview
| 0–5 | Frame the problem: stock vs crowd, limits, hold time |
| 5–10 | Must do, how well, the hot-row number |
| 10–15 | Nouns and contract (hold, then pay) |
| 15–27 | First working version 8.1–8.3, parked list |
| 27–50 | Deep dives: the counter (9.1), reserve and expiry (9.2), the edge (9.3) |
| 50–60 | Bots or bulkheads, follow-ups, wrap-up |
A 90-minute interview
| 0–8 | Frame the problem, including fairness and the rest of the shop |
| 8–15 | Must do, how well, numbers |
| 15–22 | Nouns, contract |
| 22–37 | First working version |
| 37–75 | Deep dives 9.1–9.6 and the final picture |
| 75–85 | Follow-ups: 10×, failover mid-sale, testing |
| 85–90 | Trade-offs, metrics, 10× plan |
| Level | What a strong answer shows |
|---|---|
| Mid-level | An atomic conditional decrement, holds with expiry before payment, a CDN for the page, rate limiting, one-per-customer enforcement. |
| Senior | The hot-row problem with numbers and a fix (unit rows with SKIP LOCKED, or an atomic counter with its consistency caveat), the sold-out flag at the edge, a waiting room with tokens, a waitlist for released units, idempotent payments, bulkheads. |
| Staff+ | Fairness and bot strategy as product choices (randomised rooms, raffles, account rules), operational readiness (load tests, game days, kill switch, pre-scaling), protecting partners' rate limits, reconciliation after the sale, multi-location stock. |
Common mistakes:
- Charging before reserving.
- A single stock row with no plan for contention.
- Letting 200,000 requests reach the database to learn "sold out".
- Holds that expire into nowhere.
- Trusting the client's clock.
- Forgetting the rest of the shop.
- "We'll autoscale".
13. Try it, check yourself, read more
Sale simulator. 10,000 units; 200,000 buyers arrive in the first two seconds. Payments take 3–8 s and 15% are abandoned. Buyers who wait more than 5 s for an answer give up. Once a size sells out, a cached flag answers everyone else instantly, and abandoned units go to the waitlist.
Illustrative rates: one locked row ~500 changes a second; unit rows with SKIP LOCKED ~20,000; a Redis counter ~50,000. Real numbers depend on hardware and transaction size.
Why does a single stock row struggle in a flash sale?
What goes wrong if buyers pay first and stock is taken afterwards?
How do 190,000 losing buyers learn "sold out" without touching the database?
What reliably enforces "one pair per customer" when someone opens five tabs?
| Resource | Why |
|---|---|
| We replaced Redis with MySQL for inventory reservations (Shopify, 2026) | Unit rows and SKIP LOCKED for reservations at Black Friday scale. |
| Surviving flashes of high-write traffic using scriptable load balancers (Shopify) | A leaky-bucket throttle, a cached queue page and signed admission cookies. |
| Scripting with Lua (Redis docs) | Atomic check-and-decrement in one script. |