Marketplace & Booking
Design Flash Sale
01
What the drop must do
What the drop must do
- An operator schedules a drop: the product and its variants, units per variant, start time, per-buyer limit and an end time.
- Shoppers can join a waiting room up to 30 minutes early and see a countdown driven by server time.
- At the start, everyone already waiting gets a random place in line; people who arrive later join the back in arrival order.
- Admitted shoppers get a short window to pick a variant and pay in one tap using a card already on file.
- Each account can buy at most one unit, and the limit holds across devices and retries.
- When stock runs out, everyone still waiting is told within seconds, and unpaid units come back to the line.
- No oversell: unit 2,501 of 2,500 is never sold, even if a cache node dies mid-drop.
- The waiting room holds 400,000 concurrent shoppers without the store backend noticing.
- Admitted shoppers finish checkout with p95 under 800 ms, excluding any bank challenge.
- The rest of the store keeps working normally while the drop is live.
- Bots should win no more units than their share of genuine customers would, and ideally far fewer.
- "Sold out" is a normal, polite screen, never an error page.
Ask how many units and how many people. A drop with 2,500 units and 400,000 hopefuls is a fairness and load-shedding problem, because 99% of visitors will leave empty-handed. A Prime Day deal with 200,000 units and a million visitors is closer to ordinary checkout with a hot row. Also ask whether sizes or colours split the stock, and whether payment must be completed inside the window or can be captured afterwards.
02
Sizing one drop
Sizing one drop
Assume one sneaker drop: 2,500 pairs across 5 sizes, 400,000 people in the waiting room at the start, and about a third of queue joins coming from automated clients before filtering. The table works out what each tier sees.
| Quantity | Working | Result |
|---|---|---|
| Odds of winning | 2,500 / 400,000 | about 1 in 160 |
| Queue joins in the last minute | 60% of 400K arrive in the final 60 s | about 4,000 / s, bursts near 40,000 / s at T minus 1 s |
| Status checks | 400K clients polling every 5 s | about 80,000 / s, nearly all answered by the CDN |
| Admission rate | set by the controller, see deep dive | 300 shoppers / s at the start |
| Claims per second | 300 admitted x 75% who actually tap Buy | about 225 / s |
| Time to sell out | 2,500 / 225 | roughly 11 to 15 seconds |
| Queue state in Redis | 400K tickets x about 200 B | about 80 MB |
| Unit tokens | 2,500 entries x 40 B | 100 KB, trivially small |
| Card authorisations | one per successful claim, plus retries | about 2,700 for the whole drop |
The order database sees about 225 writes a second for fifteen seconds. That is nothing. All the engineering goes into making sure the other 399,000 people, their refresh buttons and their bots never reach anything more expensive than a CDN edge.
03
API design
API design
A per-user "what is my position" endpoint means 80,000 uncacheable requests a second. Instead, give each ticket a sequence number and publish one global number: everyone up to this sequence may enter. The client compares the two locally. Personal state is only fetched once, at the moment the shopper is actually let in.
04
Architecture overview
Architecture overview
The drop runs on a separate, purpose-built path that sits beside the normal store. Most components exist to keep load away from the small core that actually hands out units.
flowchart LR
U["Shopper or bot"] --> EDGE["CDN and bot filter"]
EDGE --> BEACON["Progress beacon, 1 s TTL"]
EDGE --> QS["Queue service"]
QS --> QR[("Redis: tickets and sequence")]
AC["Admission controller"] --> QR
AC --> BEACON
EDGE --> PASS["Pass service"]
PASS --> QR
PASS --> CL["Claim service"]
CL --> TOK[("Redis: unit tokens per size")]
CL --> PAY["Payment adapter"]
CL --> LOG[["Kafka: claim log"]]
LOG --> ORD[("Orders DB")]Serves the pre-sale page and the progress beacon from cache, checks proof-of-work and device signals, and rate limits per IP and per account. Most junk dies here before it costs a CPU cycle of ours.
Issues one signed ticket per account, assigns sequence numbers at the start, and advances admitted_through at a rate tied to stock and checkout capacity. A single leader runs the controller, with a standby.
Pops a unit token for the chosen size, authorises the saved card, and appends the result to a durable log. It is the only component that touches stock during the drop.
A consumer turns claim log entries into normal orders in the main store. After the drop, a job compares paid claims, orders and starting stock and must balance exactly.
05
Deep dive: the waiting room and the unit tokens
Deep dive: the waiting room and the unit tokens
The waiting room decides who gets to try, and how fast. The token store decides who actually gets a unit. Keep them separate: the waiting room can be approximate and lossy, but the token store must be exact.
Shuffling the line at the start
Pure first-come-first-served rewards whoever has the lowest latency to your servers and the fastest refresh script. That is mostly bots and people in the same city as your data centre. A fairer rule: everyone who joins before the start time is equal. At T0 the queue service takes the set of tickets, shuffles it with a seeded random permutation, and assigns sequence numbers 1 to N. Anyone who joins after T0 gets N+1, N+2 and so on in arrival order.
This one change removes the reason to hammer the site at 11:59:59. Joining at 11:45 or 11:59 gives the same odds, so the arrival curve flattens and the burst you have to absorb gets smaller. Keep the seed and the shuffled order in the audit log so you can show a regulator or an angry customer that the draw was fair.
Admission as a feedback loop
The admission controller runs once a second. It reads three numbers: units still unclaimed, passes outstanding (admitted shoppers who have not finished), and the claim service's current p95 latency. It then advances admitted_through by an amount that keeps outstanding passes at roughly 1.3 times the units left, capped by what checkout can serve within its latency target. Early in the drop that means 300 new admissions a second. With 200 units left it slows to a trickle. When units hit zero it stops and flips the beacon to SOLD_OUT.
Admitting a little more than stock is deliberate. Some admitted shoppers hesitate, pick a sold-out size or fail a card check. If you admit exactly as many as units, the last few hundred units sit idle while their holders dither. If you admit far more, thousands of people get a pass and then a "sold out" screen, which feels worse than never being admitted.
flowchart TD
J["Join before start, get ticket"] --> SH["Shuffle at T0 assigns seq"]
SH --> P{"seq at or below admitted_through?"}
P -->|"no, poll beacon again"| P
P -->|"yes"| X["Exchange ticket for 3 minute pass"]
X --> C["Claim: pop token for chosen size"]
C -->|"token"| A["Authorise saved card"]
C -->|"size empty"| C
A -->|"approved"| W["Write claim to log, show success"]
A -->|"declined"| R["Push token back, offer retry once"]Units as tokens, not a counter
Before the drop, a loader writes one entry per physical unit into a Redis list per size: 500 tokens for size 9, each a short id like u9-0412. Claiming is a single LPOP. Redis executes commands one at a time on a shard, so two shoppers can never receive the same token, and an empty list returns nil immediately. Compared with a counter guarded by a script, a token has an identity. The claim log records exactly which unit went to which account, and returning an abandoned unit is an RPUSH of that same id.
Per-account limits sit next to the tokens: SET claimed:{drop}:{account} 1 NX runs first, and only on success does the claim pop a token. If the card is declined or the pass expires, a cleanup step deletes the marker and pushes the token back. Both keys for one drop are pinned to the same Redis shard with a hash tag, so a small Lua script can do the marker and the pop together and there is no state where a token is taken without an owner.
What about losing Redis? Run it with a synchronous replica (WAIT 1 after the pop) so an acknowledged claim exists on two nodes. If the primary still dies and a replica is promoted with a few claims missing, those tokens may be handed out twice. The claim log is the tiebreaker: the consumer that writes orders checks each token id, and the second claim of the same unit is cancelled and voided before capture. That is a rare apology, not an oversell, because no second parcel ever ships.
Paying in one tap
Typing a card number takes 30 seconds, which is two full sell-outs at our pace. Require a saved, verified card to join the queue, and check it with a zero-amount verification when the ticket is issued. The claim then authorises the real amount in one call with the pass id as idempotency key, and capture happens when the order ships. Shoppers whose bank insists on a 3-D Secure challenge keep their token for the length of the pass, so a slow bank page does not cost them the unit.
06
Tempting shortcuts to avoid
Tempting shortcuts to avoid
Each of these looks reasonable in a design review and fails within the first second of a real drop.
| Shortcut | What happens on the day | Do this instead |
|---|---|---|
| Read the stock, then write stock minus one | Thousands of requests read the same value in the same millisecond, and each writes back its own decrement. You sell far more than you have. | One atomic operation that both checks and takes: a token pop, a guarded decrement, or a conditional update. |
| Send everyone straight to the product page at T0 | The whole crowd lands on your app servers and database at once. Pages time out, retries double the load, and the normal store goes down too. | A waiting room in front, with admission paced to what checkout can serve. |
| Per-user position polling against origin | The waiting room itself becomes the hottest service, and it falls over just as the sale starts. | A shared, CDN-cached progress number that clients compare against their own ticket. |
| Ask for card details after the shopper wins | Winners spend half a minute typing while their unit sits idle, and many abandon when a bank page loads. | Verify a saved card at queue entry and authorise in one tap at claim time. |
| Trusting each server's clock for the start time | Servers a second fast open the sale early for some shoppers, and screenshots of it spread on social media within minutes. | One controller flips the drop to live; every other component reads that state. |
| Adding CAPTCHA as the only bot defence | Solving services clear it for a few cents each, and real shoppers get the friction instead. | Layer account age, verified payment, proof-of-work, device signals and one-ticket-per-account. |
07
Decisions and their costs
Decisions and their costs
Sub-millisecond, atomic by construction, and each unit has an id that follows it into the order. The cost is a loader before the drop and a second copy of stock that must be reconciled with the order store afterwards.
Best fit for small fixed stockKeeps one source of truth, and at 225 claims a second a single row can just about cope. With bucketed rows it copes comfortably. It is the safer pick if the team does not run Redis with synchronous replicas.
Viable at this claim rateRemoves the incentive to refresh at the last second, so the load curve is gentler and bots lose their speed advantage. Some customers dislike that arriving an hour early buys nothing, so say so on the page.
Fairer and easier to serveSimple to explain and to implement with a sorted set by join time. It turns the start time into a latency race that automated clients win, and it concentrates arrivals into a spike right at T0.
Fine for low-demand salesStock sells out quickly without idle units, and the claim tier never sees more than it was sized for. The controller is a small piece of logic that needs testing with recorded traffic before the first real drop.
Keeps checkout inside its budgetEasy to reason about, but either too slow when there is plenty of stock or too fast at the end, which floods shoppers with passes for units that no longer exist.
Wastes the end of the dropOne-tap checkout and an extra bot filter, because stolen-card farms cannot easily pass verification at scale. It costs a small verification fee per ticket, which is cheap for a 15,000 rupee sneaker and expensive for a 200 rupee phone case.
Worth it for high-value dropsGuarantees funds for every winner, but you place 400,000 holds to sell 2,500 units. Banks may flag the pattern, and shoppers see pending charges they never agreed to keep.
Too many holds08
Failure modes
Failure modes
A reseller group creates 30,000 accounts the week before and joins the queue with all of them. Even with a fair shuffle, they hold 7% of tickets and walk away with a matching share of the stock.
Make joining expensive in ways people do not notice and bots do: account age, a verified card, one ticket per card fingerprint and per delivery address, and proof-of-work tuned to cost a browser a second. Cancel winning orders that share an address or card cluster.The Redis primary holding the size 9 list crashes. The promoted replica may be missing the last few pops, so the same token could be handed out twice.
Acknowledge a claim only afterWAIT confirms the replica has it. The order writer treats token id as unique and cancels a duplicate before capture. Pause admission for the affected size for a few seconds during failover rather than guessing.
The claim succeeded but the response was lost on a mobile network. The app retries, and without protection it pops a second token and authorises the card again.
The pass id is the idempotency key. The claim service stores the outcome under it and returns that outcome for every repeat, and the per-account marker blocks a second unit even with a different pass.A pod with a drifting clock decides it is already 12:00 and starts serving the product page 1.8 seconds early.
No component decides the start time on its own. The controller flips one state key and bumps the beacon, and passes are only issued once that key says live. Clients show a countdown computed from server time returned at join.A shopper gets a pass, pops a token, then hits a bank challenge and gives up. If the unit is lost with them, the drop ends with stock unsold and people still waiting.
A claim that is not paid within the pass window is released by a sweeper, which pushes the token back to the head of the list and lets the controller admit one more shopper.A misconfigured cache rule holds the progress beacon for a minute instead of a second. Admitted shoppers do not know they are in and their passes expire unused.
Set a shorts-maxage and test it in a dress rehearsal. Make pass validity start when the shopper exchanges the ticket, not when they were admitted, so a late beacon delays them without punishing them.
09
How to run the interview
How to run the interview
Lead with the odds. Say how many people will leave without a unit. Once the interviewer hears "1 in 160", it is obvious that protecting the backend from losers is the main job.
Separate admission from allocation. The waiting room paces traffic; the token store guarantees correctness. Mixing them is the most common muddle.
Show why the beacon can be cached. A shared number plus local comparison turns 80,000 requests a second into a CDN problem. Interviewers rarely hear this and remember it.
Explain atomicity precisely. Name the operation, say why two callers cannot both succeed, and then say what happens to an acknowledged claim when the node dies.
Treat bots as a requirement. Without a bot plan the design technically works and the business still fails. Layer defences and admit that none of them is perfect.
Contrast with seat booking. A ticketing site sells distinct seats with holds and a map. A drop sells interchangeable units, so a list of tokens or a counter is enough and the hard part shifts to the crowd.
10
How the design grows
How the design grows
The existing store handles it. UPDATE ... WHERE stock >= 1 keeps it correct, and a per-account unique constraint enforces the limit.
The drop page moves to the CDN, and a hosted or home-built waiting room meters entry. The store backend stops seeing the crowd. This step prevents most outages.
Stock moves to Redis token lists with synchronous replicas, and claims flow through a durable log into orders, with reconciliation after every drop.
Shuffle at start, verified cards at queue entry, proof-of-work, and post-drop cancellation of clustered orders.
Each region gets its own queue, token lists and payment adapter, with stock allocated per region in advance. Nothing in the hot path crosses an ocean.
Watch and read
Try next
Free to read · better with landed
Whiteboard this with landed
landed runs it as a live system design round on the whiteboard and grades your trade-offs.
