Engineering Atlas › System Design Interviews

Design a high-demand flash sale

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: building the payment processor, recommendation and search, multi-SKU carts during the sale (we allow one sale SKU per order), and shipping.

Back-of-envelope

Planning assumptions: 100,000 units of one phone, limit 2 per user, starting at noon.

API

GET  /v1/sales/{saleId}                      -> {sku, price, startsAt, limit, state:"upcoming|live|sold_out"}
POST /v1/sales/{saleId}/queue                -> {queueToken, position, etaSeconds}
GET  /v1/sales/{saleId}/queue/{queueToken}   -> {state:"waiting|admitted", admitToken?}

POST /v1/sales/{saleId}/reservations
  Headers: Admit-Token, Idempotency-Key: <uuid>
  {qty: 1}
  -> 201 {reservationId:"r_81", expiresAt}
  -> 409 {error:"sold_out" | "limit_reached"}

POST /v1/reservations/{reservationId}/order
  Headers: Idempotency-Key: <uuid>
  {paymentMethodId}
  -> 201 {orderId:"o_55", status:"pending_payment|paid"}

GET  /v1/orders/{orderId}

The admit token is a short-lived signed token (user ID, sale ID, expiry) that the reserve endpoint verifies statelessly. The reserve and order endpoints both accept client-generated idempotency keys.

Data model

High-level design

flowchart LR
  U["Users"] --> CDN["CDN: page + countdown"]
  U --> EDGE["Edge: rate limit, bot filter"]
  EDGE --> WR["Waiting room"]
  WR -- admit token --> RS["Reserve service"]
  RS --> RD[("Redis: stock buckets, user counts")]
  RS --> DB[("Order DB: stock, reservations")]
  RS --> OS["Order service"]
  OS --> DB
  OS --> PAY["Payment provider"]
  PAY -- webhook --> OS
  SW["Expiry sweeper"] --> DB
  SW --> RD
  REC["Reconciler"] --> DB
  REC --> RD

Write path. Before the sale, a loader writes stock buckets to Redis and SQL and warms the sale record into every cache. At noon, the CDN keeps serving the static page while clicks go to the edge, which applies per-IP and per-account rate limits and bot scoring, then to the waiting room. The waiting room hands out admit tokens at a fixed rate, randomising order among everyone who arrived in the first second so that a few milliseconds of network advantage do not decide the winner. An admitted user calls reserve. The reserve service runs one Lua script that checks the idempotency key, checks the user's count against the limit, picks a bucket with stock, and decrements. On success it inserts the reservation row and conditionally decrements the matching SQL bucket in one transaction. Then the user pays; the order service creates the order with the reservation ID as its unique key and the payment intent with the same key, and marks the order paid when the provider's webhook arrives.

Read path. The sale page, price and countdown come from the CDN. "Is it sold out?" comes from a single flag in Redis that the reserve service flips when all buckets hit zero, replicated to the edge config so the edge can answer "sold out" without touching origin. Order status reads go to the order DB through a cache.

Deep dives

Atomic decrement on a hot key

The naive GET stock; if > 0 then DECR has the classic race. Options considered. A SQL conditional UPDATE on one row is correct, but every buyer serialises on its row lock and WAL commit, topping out around a few thousand updates per second. Alibaba hit exactly this wall: in 2012 its inventory database ran out of capacity at midnight on Double Eleven, and the fix was to turn the lock scramble on a hot item's row into an orderly queue inside the database (Alibaba Cloud). Plain Redis DECR with a compensating INCR on a negative result is fast but lets the counter dip below zero. Third, a Lua script that reads, checks and decrements atomically, which Redis guarantees: while a script runs, all other server activity is blocked (Redis docs).

Choice: the Lua script, plus bucketing. One Redis key lives on one shard and one core, which handles roughly tens of thousands to around 100,000 simple scripts per second. That leaves little headroom at 50,000 admits per second, so we split stock into 16 buckets across shards. The reserve service hashes the user to a starting bucket and, if that bucket is empty, tries the next few. Near the end, buckets drain unevenly and some users see "sold out" while another bucket still has a few units. That is undersell, which we accept and fix with a "last units" sweep that consolidates leftovers into one bucket after the first minute.

Per-user limit and fairness

The limit must be checked atomically with the decrement, or a user firing 20 parallel requests can pass the check 20 times. So the same script reads sale:{id}:user:{uid}, rejects if count + qty > limit, and increments it alongside the stock. That requires the user key and the bucket key in the same Redis Cluster slot, which conflicts with spreading buckets across shards. The fix is to route by user: hash the user ID to choose a bucket, place the user's counter in that bucket's slot with a hash tag, and only fall back to other buckets through a second script that checks the limit key first. A durable copy in user_sale_counts catches anyone who slips through a Redis failover.

Admission order matters too. Shopify's first flash-sale throttle let a fixed number of checkouts through per window and had everyone else poll; because polling was effectively a lottery, some buyers waited up to 40 minutes, about as long as the sale (Shopify Engineering). That is why the waiting room here randomises only within the first second and is first-come, first-served after that.

The limit is per account, so fairness also depends on account quality: require login, require accounts older than a few days for high-demand sales, run bot scoring at the edge, and make the waiting room the only way to get an admit token.

From reservation to paid order, exactly once

Redis and the SQL database cannot commit together, so a crash between the two writes leaves them disagreeing. Options considered: two-phase commit (not supported across Redis and SQL, and slow), Redis as the only truth (asynchronous replication means a failover can forget decrements and oversell), or SQL as the truth with Redis as a filter.

Choice: SQL is the truth. Redis lets through at most about the number of units in stock, so only around 60,000 requests ever reach the database, at a rate it can handle. The reservation transaction does UPDATE stock_buckets SET remaining = remaining - $q WHERE sale_id=$s AND bucket=$b AND remaining >= $q and inserts the reservation and user-count rows. If the conditional update matches nothing, Redis was optimistic; we return "sold out" and increment the Redis counter back. Order creation is idempotent through the unique reservation_id, and the payment intent uses the reservation ID as its idempotency key, so a double-clicked "pay" produces one order and one charge. A sweeper expires unpaid reservations, returning units to the SQL bucket and then to Redis; if the Redis increment is lost, the reconciler fixes it later. The error direction is always undersell.

Down the stack

Layer 1The contract

Reserve either returns a reservation for exactly qty units or a definite refusal (sold out, limit reached). The sum of active and paid reservations never exceeds the sale's units, and no user's total exceeds the limit, as recorded in the order DB. Retrying any call with the same idempotency key returns the original result and has no further effect. Redis state may briefly undercount available stock (undersell) but never lets the DB oversell, because the DB re-checks.

Layer 2The mechanism

Redis processes commands on one main thread. EVALSHA runs a cached Lua script as a single command, so no other client's command interleaves with it: the GET of the idempotency key, the comparison against the user limit, the DECRBY on the bucket and the INCRBY on the user counter are observed by everyone as one step. The script must declare every key it touches in KEYS so Redis Cluster can verify they share one of its 16,384 hash slots, which is what the {...} hash tag guarantees (Redis Cluster spec); a cross-slot script is rejected with CROSSSLOT. The script cache itself is volatile: a restart or a failover to a replica can empty it, and EVALSHA then fails with NOSCRIPT until the client reloads the script (Redis docs). Since Redis 7.0, Redis Functions (FUNCTION LOAD, then FCALL) are the alternative: the code is persisted to the AOF and replicated to replicas like data, though in a Redis Cluster you still load it on every primary yourself (Redis docs). Writes go to the replication stream asynchronously, and the WAIT command can block until N replicas acknowledge, at a latency cost. Even then Redis does not promise the write survives: the docs say WAIT does not make Redis strongly consistent, and a failover can still lose a write that reached replicas (Redis docs). In the SQL tier, the conditional UPDATE ... WHERE remaining >= $q takes a row lock on the bucket row; concurrent transactions queue on that lock, and under Read Committed each re-evaluates the predicate against the latest committed value when it gets the lock, so the count never goes negative.

Layer 3The machine

A small Lua script runs in perhaps 5 to 20 microseconds of CPU. Redis spends more time on networking than on the script: each request is a read from a socket that epoll_wait reported ready, RESP parsing, and a write back. Redis 6+ can offload socket reads and writes to I/O threads, but the script itself runs on one core, so one shard handles on the order of 100,000 scripts per second at best. Round-trip time inside one availability zone is about 0.2 to 0.5 ms, so pipelining and connection pooling matter more than script speed. With AOF set to appendfsync everysec, the default policy, a crash can lose up to about one second of decrements (Redis docs), which is the formal reason Redis cannot be the ledger. On the SQL side, each bucket row update costs a WAL append and, at commit, an fdatasync on NVMe of around 50 to 200 microseconds, amortised by group commit across concurrent transactions.

Why it's built this way

  1. Demand exceeds supply by 100x or more, so the cheapest possible "no" must happen as early as possible: CDN, edge, waiting room, then memory, then disk.
  2. A single counter must be updated atomically, and atomic updates on one key serialise on one core, so we split the counter into buckets and accept a little undersell at the tail.
  3. In-memory stores replicate asynchronously and fsync lazily, so they cannot be the ledger; the durable database re-checks every decision that matters.
  4. Networks and users retry, so every step carries an idempotency key and a unique constraint that turns duplicates into lookups.
  5. Payments are slow and fallible, so reservations expire and return stock instead of holding it forever.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
SQL conditional decrement onlySimple, durable, correctOne hot row caps at a few thousand per secondSmall sales, stock under a few thousand
Redis Lua gate + SQL truthAbsorbs huge bursts, DB stays correctTwo stores to reconcile, slight undersellLarge, spiky sales (our choice)
Redis as the only truthFastest, simplestFailover can oversellLosing a few units is acceptable
Queue all requests, single consumerPerfect ordering, no contentionUsers wait for a result; consumer is a bottleneckLottery-style or "register interest" sales
Bucketed countersSpreads load across cores and shardsUneven drain, tail undersellAny key above about 50,000 updates per second

Leadership lens

Check yourself

  1. Why is the in-memory Redis decrement treated as a ticket to try rather than the sale itself?

    Show answer

    Redis replicates asynchronously and fsyncs lazily, so a failover or crash can forget recent decrements and oversell if it were the ledger. SQL is the truth: its conditional UPDATE ... WHERE remaining >= $q re-checks every reservation, and Redis only filters traffic down to roughly the number of units in stock.

  2. The Redis primary fails in the middle of the sale. What do users see, and why is nothing oversold?

    Show answer

    The replica may lack the last few decrements and believe more stock exists, but the SQL conditional decrement rejects the excess, so those users see "sold out" a moment later than ideal. Admission is paused for a few seconds during failover.

  3. What does splitting stock into 16 buckets buy, and what does it cost?

    Show answer

    One Redis key lives on one core that handles at most around 100,000 scripts per second, which leaves little headroom at 50,000 admits per second, so buckets spread the load across shards. The cost is uneven drain near the end, where some users see "sold out" while another bucket still has units, an undersell fixed by a "last units" sweep.

  4. With 10M users clicking within 10 seconds, what request rate must the edge handle, and why does the backend not see it?

    Show answer

    About 10M times 1.5 attempts over 10 seconds, roughly 1.5M requests per second. The waiting room admits a fixed rate such as 50,000 users per second, so backend load stays the same however large the crowd, and only queue positions grow.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →