Skip to main content
PProfileMatch

HardProblem 32 of 36 in the system design series

Design a Flash Sale: Limited Stock vs a Wall of 10M Buyers

Cover illustration: Design a Flash Sale: Limited Stock vs a Wall of 10M Buyers

Picture a new phone launching at exactly noon with 100,000 units (one lakh) available, and ten million people pressing the buy button within the same few seconds. A regular online store sees traffic that rises and falls gently through the day. A flash sale sees almost nothing, then a vertical cliff of requests, then silence again once the stock is gone.

Interviewers use this problem to test whether a candidate can reason about contention. Thousands of requests compete for the same small counter, so naive locking collapses, and a careless optimisation can sell more phones than exist. The candidate also has to keep buyers informed, handle people who reserve but never pay, and make sure the sale does not drag down the normal shop running next to it.

The winning shape is to refuse most traffic as early and cheaply as possible, protect the inventory count with a single atomic operation, and push slower work like order writes and payments behind a queue.

This is an interview practice design for a Flash Sale-like service, built from first principles. It is not a description of how Flash Sale is actually built.

Functional Requirements

Before noon, anyone can open the product page and watch a countdown to the start. Once the sale begins, a buyer can claim one unit while supply remains, and the one-per-account rule stops resellers from scripting dozens of purchases. A claim is not yet a sale: the buyer gets a fixed window, about ten minutes, to complete payment, after which the unit returns to the pool for the next person.

The fourth requirement is easy to overlook but matters a lot to users. Everyone should learn their fate quickly and clearly, whether that is "you have one", "you are still in line" or "sold out". A spinner that never resolves pushes people to refresh, and refreshes multiply the load we are trying to control.

Sale page & countdown

Users see the product and a timer before the sale opens.

starts at 12:00

Reserve an item

At sale time, a buyer can grab one unit while stock lasts.

1 per user

Pay to confirm

The buyer must pay within a few minutes, or lose the item.

10 min to pay

Clear status

Tell users quickly: you got it, you are waiting, or sold out.

no silent failures

Non-Functional Requirements

The system has to survive a burst on the order of 500K requests per second without any component falling over. It must never confirm more orders than there are units, which means the inventory count needs strong consistency even while everything else is relaxed for speed.

Fairness is a product promise as much as a technical goal. Buyers expect something close to first come, first served, and automated scripts should not sweep up the whole stock. Finally, the sale needs isolation from the rest of the business. If the event overwhelms shared services, ordinary customers browsing other products should still be able to add items to a cart and check out.

Survive the spike

Huge traffic in the first seconds must not crash anything.

500K req/sec

Never oversell

Sold units can never be more than the stock.

strong consistency

Fair to buyers

Roughly first come, first served; bots don't win.

fair ordering

Protect the rest

The normal store keeps working during the sale.

isolation

Scale Estimation

Assume 10 million buyers are ready when the sale opens and most of them act within roughly 20 seconds. That works out to about 500K requests per second, and impatient refreshing can push the real number higher. Against that, we have only 100K units, so about 1% of buyers can possibly succeed.

The order volume itself is modest: 100K inserts is a light job for any relational database. The difficulty is entirely in the 99% who will leave empty-handed. If each of those requests travels all the way to the database before being refused, the database dies. The estimate therefore tells us where to spend effort, which is on turning people away at the edge with as little work as possible.

10million

Buyers at sale start

500K / sec

Peak requests

10M in about 20 seconds

100K

Units in stock

one lakh phones

1%

Buyers who succeed

100K of 10M

99% of buyers will be turned away → reject them early and cheaply

Core Entities & API

Four entities model the flow: the Sale (product, start time), its Inventory count, a Reservation that holds one unit for a limited period, and the Order that a paid reservation becomes. Separating reservation from order lets the system hand out a unit instantly and settle payment later.

GET /sales/{id} returns the product details and start time. Because it is identical for everyone, it is cached at the CDN. POST /sales/{id}/reserve carries a queue_token from the waiting room and returns either a reservation_id with an expires_at time or a sold-out response. POST /orders takes the reservation ID and a payment method, creates the order and kicks off payment, returning the order ID and its status.

Core entities

SaleInventoryReservationOrder

API

  • GET /sales/{id}→ { product, starts_at } (CDN)
  • POST /sales/{id}/reserve { queue_token }→ { reservation_id, expires_at }
  • POST /orders { reservation_id, payment_method }→ { order_id, status }

High-Level Design

Static assets, the product page and the countdown are served by the CDN, so the crowd gathering before noon never reaches our origin. When the clock hits twelve, clients move into a virtual waiting room that releases people at a steady rate the backend has been sized for.

An admitted buyer calls the reserve service, which performs a single atomic decrement on a stock counter held in Redis. If the counter is already at zero, the buyer immediately hears that the item is sold out. Successful reservations are published to a Kafka topic, and the order service consumes them, writing a pending order to Postgres.

The buyer then pays through an external payment provider, and a successful charge moves the order to paid. If the window closes without payment, the order service cancels the order and increments the Redis counter, which makes that unit available to someone still waiting.

admitteddecrementreservedchargerelease on timeoutClientweb · appCDNsale page · timerWaiting roomadmits N / secReserve serviceholds one unitStock counterRedis · atomicOrder queueKafkaOrder servicepending → paidOrder DBPostgresPayment providerPSP
High-level design. Dashed arrows are taken only on a cache miss or as a fallback. Scroll sideways on small screens.

Deep Dive 1 — Absorbing the Spike

Absorbing the burst is a layered defence. The first layer is the CDN: every byte of the sale page, images and timer comes from edge servers, which remove the bulk of pre-sale load. The second layer is the waiting room. At opening time each buyer receives a position in a line, and storing a position costs almost nothing compared with processing a purchase.

The waiting room then admits buyers at a controlled pace, for example 20K per second, chosen to match what the reserve service handles without strain. Every admitted buyer gets a signed entry token, and the reserve API rejects calls that lack a valid one, so nobody can bypass the line by hitting the endpoint directly with a script.

As soon as the counter reaches zero, the waiting room broadcasts a sold-out notice to everyone still queued, which stops a flood of hopeless retries. Bot detection, the one-unit-per-account rule and a dedicated cluster for the sale complete the picture, so the regular storefront keeps its own capacity.

  1. 1Static from the CDNPage, images and countdown never hit our servers
  2. 2Join the waiting roomEach buyer gets a place in line at 12:00
  3. 3Admit at a fixed ratee.g. 20K buyers per second, matched to capacity
  4. 4Signed entry tokenReserve API only accepts admitted buyers
  5. 5Sold out, earlyWhen stock hits zero, tell everyone in line
  • Plus: bot checks, one per user, and a separate cluster from the main store

Deep Dive 2 — No Overselling

Locking the inventory row in the database is correct, but every buyer contends for that one lock, and the line of waiting transactions grows faster than it drains. A conditional update such as UPDATE ... SET stock = stock - 1 WHERE stock > 0 avoids an explicit lock and stays correct, yet it still funnels all writes through a single hot row.

The chosen option moves the counter into Redis and changes it with a Lua script. Redis executes scripts one after another, so the check and the decrement form one indivisible operation with no gap for a competing request. The script verifies that this user has not already claimed a unit, confirms stock is above zero, then decrements and records the user, all in memory in well under a millisecond.

Redis acts as the fast gate, but the database remains the source of truth. A unique constraint on the pair of sale and user, plus a check that confirmed orders never exceed stock, catches any inconsistency left behind if Redis fails over and loses a recent write.

Row lock in the DB

  • + Simple and correct
  • − One hot row → lock queue
  • − Thousands wait in line

Conditional UPDATE

  • + No explicit lock
  • + Correct: stock > 0 check
  • − Still one hot row

Atomic Redis scriptChosen

  • + In-memory, very fast
  • + Check + decrement at once
  • − DB must reconcile
  • Lua: user already bought? stock > 0? → decrement + add user
  • Final guard in the DB: unique (sale, user) and order count ≤ stock

Deep Dive 3 — Orders & Payment Timeouts

A reservation holds a unit for ten minutes and turns into a sale only once payment clears. Order creation is asynchronous: Kafka soaks up the burst of 100K reservations and the order service writes them to Postgres at a rate it can sustain, so the database never sees the spike directly.

Payments must be idempotent. The order ID is passed to the provider as the idempotency key, so a network retry cannot charge a card twice, and the provider's webhook reports the final outcome. A delayed job revisits each order after ten minutes; if it is still unpaid, the job cancels it and increments the Redis counter so the next buyer in the queue gets a chance.

Because Redis, the order table and the payment records can drift after crashes or failovers, a periodic reconciliation job compares all three and corrects mismatches, for example restoring a unit whose order was cancelled but whose counter increment was lost.

  • Reservation with an expiry

    A unit is held for ten minutes, not sold, until payment succeeds.

  • Async order creation

    Kafka smooths the burst; the order service writes at its own pace.

  • Idempotent payments

    Order ID as the idempotency key; a webhook confirms the result.

  • Release on timeout

    Unpaid orders are cancelled, and the unit goes back to Redis.

  • Reconcile counts

    A job compares Redis, orders and payments, and fixes any drift.

Trade-offs

The waiting room keeps the system alive, but buyers lose the feeling of instant access and must sit in a line. The Redis counter is dramatically faster than a database lock, at the cost of reconciliation jobs and a database-level guard. The payment window is a balance between buyer convenience and how quickly abandoned units return to the pool.

Running the event on a separate cluster shields the main store at the price of extra infrastructure that sits mostly idle between sales. The guiding idea across all four: soak up the surge before it reaches the core, decrement inventory atomically, and let unpaid holds expire back into stock.

  • Waiting room vs instant access

    Keeps the system alive, but buyers must wait in line.

  • Redis counter vs database lock

    Huge speed, but we need reconciliation and a database guard.

  • Long vs short payment window

    Longer is kinder to buyers; shorter returns unsold stock faster.

  • Separate cluster for the sale

    Protects the main store, but costs extra infrastructure.

Absorb the spike. Decrement atomically. Pay later, release on timeout.

Key takeaways

  • About 99% of buyers will fail, so reject them at the CDN and waiting room, not the database.
  • Admit buyers at a fixed rate with signed tokens that the reserve API checks.
  • A Redis Lua script makes check-and-decrement atomic; the database keeps a final guard.
  • Reservations expire after a short payment window and release stock automatically.
  • Use idempotent payments, async order writes via Kafka, and a reconciliation job.

Interview questions

How do you prevent overselling in a flash sale?

Keep the live stock count in Redis and change it only through a Lua script that checks the user has not bought yet, confirms stock is above zero, then decrements and records the purchase as one atomic step. Back it with a database guard, a unique constraint on the sale and user pair plus a cap on confirmed orders, in case Redis fails over.

How does a virtual waiting room work for high traffic sales?

When the sale opens, each visitor receives a place in a queue instead of hitting the purchase API directly. The waiting room releases people at a fixed rate that matches backend capacity and gives each a signed token. The reserve endpoint accepts only valid tokens, so the line cannot be skipped, and queued users learn quickly when stock is gone.

What happens if a flash sale buyer does not pay in time?

The reservation carries an expiry, typically around ten minutes. A delayed job checks the order once that time passes, and if payment has not succeeded it cancels the order and increments the Redis counter. The unit then becomes available to the next buyer in the waiting room, and reconciliation catches any missed releases.

Why use Kafka between reservation and order creation?

Successful reservations arrive in a sharp burst during the first seconds of the sale. Publishing them to Kafka lets the reserve service answer buyers instantly while the order service writes pending orders to the database at a steady pace. The queue decouples the spike from the database and also gives durable replay if a consumer crashes.

How do you stop bots from buying all the stock in a flash sale?

Combine several measures: bot detection when users join the waiting room, a strict one-unit-per-account rule enforced inside the atomic Redis script, signed entry tokens so scripts cannot call the reserve API directly, and a unique database constraint on sale and user. Together they keep ordering close to first come, first served.

Keep practising