InterviewKata
System design · Worked design D24 of 46 · Location and marketplaces · Long form

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:

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

  1. A buyer can try to secure one unit when the sale opens, and is told quickly whether they got one.
  2. A buyer who secured a unit can pay within the hold time; then the order is confirmed.
  3. Everyone sees the sale's state: countdown, open, their place in line, sold out.

Not now:

4. How well it must do it

TargetWhy this number
Never oversell: units sold ≤ stock, always consistencyCancelling 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 shapeA 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 safetyUnsold 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 · rulesFlash 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 failuresA sale that takes down everyday checkout costs more than the sale earns.

5. Numbers that change the design

QuantityWorking→ so we…
Peak arrivals200,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 rowA database row update under a row lock takes ~1–2 ms, one at a timeA single stock row tops out around 500–1,000 changes a second → so one row can't be the counter
Actual winners10,000 unitsOnly 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"
Payments10,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

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)

  1. Every buy request queues on one row lock; most time out before reaching it.
  2. 200,000 requests hit the app servers at once, mostly for people who can't win.
  3. Holds released by the sweeper go back to a stock nobody is watching any more.
  4. Bots with thousands of accounts take most of the stock.
  5. The sale shares databases and servers with the everyday shop.
  6. 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

ApproachWhy
✗ BadOne stock row per size, updated with available - 1 under a row lockCorrect, 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.
~ GoodAn 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.
✓ GreatOne 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

9.3 Absorbing the crowd at the edge fast answer · isolated

ApproachWhy
✗ BadLet every click reach the app servers and autoscaleAutoscaling takes minutes; the spike lasts seconds. Servers saturate, and timeouts make people retry.
~ GoodRate-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.
✓ GreatA 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 waitlistThe 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

9.5 Keeping the rest of the shop alive isolated

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

FailureWhat users seeHow we detect itWhat limits the damage
Hold database slowsLonger wait in the roomHold latency; admission backlogAdmission rate drops automatically; nobody gets errors
Waiting room misconfigured (admits too fast)Timeouts on BuyHold error rate; origin request rateLower the admission rate live; bulkheads protect the rest of the shop
Payment provider outage"Payment delayed" with the hold keptPayment errors and latencyExtend holds; retry with backoff and idempotency keys
Sweeper stopsReleased units appear lateExpired-but-active holds countBuy query treats expired holds as free; waitlist worker restarts
Bot waveReal people lose outAccounts per address or card; challenge failure rateChallenges in the room; one-per-account key; post-sale cancellations
Wrong price or stockSale pausedAlerts on discount and stock sanity; sales-rate anomalyKill switch at the edge; config approval

12. Wrap-up and the clock

Trade-offs we made:

What we'd watch:

At 10×:

A 60-minute interview

0–5Frame the problem: stock vs crowd, limits, hold time
5–10Must do, how well, the hot-row number
10–15Nouns and contract (hold, then pay)
15–27First working version 8.1–8.3, parked list
27–50Deep dives: the counter (9.1), reserve and expiry (9.2), the edge (9.3)
50–60Bots or bulkheads, follow-ups, wrap-up

A 90-minute interview

0–8Frame the problem, including fairness and the rest of the shop
8–15Must do, how well, numbers
15–22Nouns, contract
22–37First working version
37–75Deep dives 9.1–9.6 and the final picture
75–85Follow-ups: 10×, failover mid-sale, testing
85–90Trade-offs, metrics, 10× plan
LevelWhat a strong answer shows
Mid-levelAn atomic conditional decrement, holds with expiry before payment, a CDN for the page, rate limiting, one-per-customer enforcement.
SeniorThe 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:

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?

Updates to one row are serialised; unit rows with SKIP LOCKED let buyers claim different rows in parallel.

What goes wrong if buyers pay first and stock is taken afterwards?

Payments take seconds; during that time the stock counter hasn't moved, so far more people pay than there are units.

How do 190,000 losing buyers learn "sold out" without touching the database?

Once stock is zero, the answer is the same for everyone and can be cached.

What reliably enforces "one pair per customer" when someone opens five tabs?

The database rejects the second hold, and the claim rolls back with it.
ResourceWhy
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.