Flash-sale checkout

Checkout for a festival flash sale: five million shoppers in the first minute, and a few thousand units. The design turns away almost everyone cheaply and fairly. It makes overselling impossible with an atomic gate in memory plus a conditional update in the database.

Design the checkout for a flash sale: five million shoppers arrive in the first minute, a few thousand units are on offer, and nothing may be oversold.

Requirements

Functional

  • Shoppers see the sale page and a live "available" or "sold out" state.
  • A shopper can reserve one unit, with a limit of one per account. The reservation is held for 10 minutes while they pay.
  • Paying within the hold turns the reservation into an order. An unpaid hold expires, and the unit returns to stock.
  • When stock runs out, everyone else quickly gets a clear "sold out", not errors or endless spinners.

Non-functional

  • Peak: about 5 million shoppers in the first 60 seconds, which is about 83,000 arrivals a second, plus retries.
  • Correctness: never oversell. Sold units must stay at or below stock (5,000 units) at all times.
  • Fairness: roughly first come, first served. Bots and scripts must not take most of the stock.
  • Latency: a shopper gets "reserved", "in queue" or "sold out" within 2 seconds at p99 (99 out of 100 shoppers).
  • Isolation: the rest of the shop (normal orders, other products) keeps working during the sale.

Out of scope

  • Catalogue management and setting the sale price.
  • The payment processor's internals. We call a UPI payment gateway, and it reports back by webhook (an HTTP call to our server).
  • Delivery and fulfilment after the order is placed.

Estimate

  • Arrivals: 5M shoppers / 60 s ≈ 83,000 a second in the first minute.
  • Total requests: if each shopper makes about 10 requests in that minute (page, images, status polls, a buy click or two), 50M / 60 ≈ 830,000 requests a second. Nearly all of it must be served by a CDN, not our servers.
  • Buy attempts: if every shopper clicks buy once or twice → about 80,000 to 170,000 reserve calls a second at the very top of the spike.
  • Possible successes: only 5,000 units, so 5,000 / 5,000,000 = 0.1% can win; 99.9% of the work is saying "no" quickly and cheaply.
  • Stock counter: one SKU key on one in-memory node; an atomic decrement there handles tens of thousands of operations a second, so a waiting room in front keeps it well inside that.
  • Database: only reservations that pass the gate reach it, about 5,000 plus some expired-and-reclaimed units over 10 to 20 minutes, a handful of writes a second at most. Storage: 5,000 orders x ~1 KB = 5 MB.

API

  • GET /v1/sales/{sale_id} -> 200 {status, starts_at, available_hint} (served from CDN, refreshed every 1 to 2 s)
  • POST /v1/sales/{sale_id}/queue -> 202 {queue_token, position_estimate} (waiting room entry)
  • POST /v1/sales/{sale_id}/reserve {admission_token, idempotency_key} -> 201 {reservation_id, expires_at} | 409 {sold_out}
  • POST /v1/orders {reservation_id, idempotency_key} -> 201 {order_id, payment_intent}
  • GET /v1/reservations/{id} -> 200 {status: held|paid|expired}

Data model

  • Counter (in-memory store): stock:{sale_id}:{sku} -> integer remaining; per-user marker bought:{sale_id}:{user_id} with SET NX.
  • inventory(sku PK, sale_id, total, available CHECK (available >= 0), version): the real stock count in PostgreSQL.
  • reservations(reservation_id PK, sale_id, sku, user_id, status, expires_at, UNIQUE(sale_id, user_id)).
  • orders(order_id PK, reservation_id UNIQUE, user_id, amount_paise, payment_status, created_at).

A request, step by step

  1. Before 12:00, the sale page, images and a small status JSON are on the CDN. A CDN (content delivery network) is a set of servers near users that cache content. Five million phones load the page from these edge servers, and our own servers see almost none of that traffic.
  2. At 12:00, shoppers press "Join". The gateway drops obvious bots and allows one attempt per account every few seconds.
  3. The waiting room gives out queue positions. It releases signed, short-lived admission tokens at a fixed rate, say 2,000 a second.
  4. An admitted shopper calls reserve. The sale service checks the token, then runs one Lua script on the stock counter. The script does SET NX bought:{user} to allow one per account. If stock is above 0, it then runs DECR.
  5. If the script returns sold out, the instance sets a local sold-out flag. It then answers 409 (Conflict, here meaning sold out) to everyone else at once.
  6. A successful decrement publishes a reservation message to Kafka and returns "reserved, pay within 10 minutes". The order writer runs UPDATE inventory SET available = available - 1 WHERE sku = ? AND available > 0, and inserts the reservation row. If zero rows are updated, the database says no and the reservation is cancelled.
  7. The shopper pays through the UPI payment gateway, and its webhook marks the order paid. The writer releases unpaid reservations past expires_at. The row becomes expired, and the unit goes back to both the database and the counter, so a waiting shopper can get it.
  8. The status JSON on the CDN is refreshed every second or two from the counter. So the page shows "sold out" without anyone asking our servers.

Deep dives

Making overselling impossible

Weak: read the stock, then write the order. SELECT available; if it is above 0, INSERT the order and UPDATE available. Two requests read "1 left" at the same moment, and both succeed. That is how you sell 5,040 units out of 5,000.

Doing this in the database at 80,000 requests a second also overloads it. Every request competes for the same hot row.

Good: an atomic conditional update in the database. Run UPDATE inventory SET available = available - 1 WHERE sku = ? AND available > 0, and add CHECK (available >= 0). The database applies updates on that row one at a time, so overselling is impossible.

But under contention (many requests waiting for the same row), one hot row manages only a few hundred to about a thousand updates a second. So the database must not see the whole stampede.

Great: a fast atomic gate in memory, with the database as the final truth. An atomic DECR on an in-memory counter rejects 99.9% of requests in under a millisecond. It runs in a Lua script that also enforces one unit per account. Only the roughly 5,000 winners go on, through a queue partitioned by SKU, to the conditional update in the database.

The database stays the guarantee. Suppose the counter drifts, for example when a failover loses a decrement. The database still refuses the extra unit, and the shopper sees "sorry, sold out" instead of an order that cannot be fulfilled.

Trade-off: two places hold a stock number, so you need a reconciliation job and a drift alert. In return, correctness lives in the database and speed lives in memory.

Absorbing five million people in a minute

Weak: autoscale the servers and hope. Autoscaling takes minutes, but the spike lasts one minute. Even if compute keeps up, everything funnels into one SKU, one counter and one row. More servers do not fix that.

Good: a CDN for everything static, plus rate limits. The page, images and a status JSON refreshed every second or two come from the CDN. So about 830,000 requests a second never reach us. Per-user and per-IP limits at the gateway cut the scripted retries. The rest of the shop runs on separate service instances, so normal orders are not hurt.

Great: a waiting room that admits shoppers at the rate the back end can take. Shoppers join a virtual queue and get a position. Signed admission tokens are released at a fixed rate, say 2,000 a second, and only token holders can call reserve.

The moment stock reaches zero, the waiting room tells everyone still in the queue "sold out" and stops admitting. Users see honest progress instead of errors. Load on the reserve path is flat and predictable, and the order of joining gives rough fairness.

Trade-off: the waiting room adds a step and a little latency for the lucky first few. You must also pick the admission rate in advance, so load-test it. In return, the hardest minute of the year becomes a steady, predictable 2,000 requests a second.

Holds, payment and fairness

Weak: decrement stock only after payment succeeds. Paying takes 30 to 90 seconds on a phone. So thousands of shoppers start paying for the same last units, and most get refunds. The opposite mistake is to decrement at "add to cart" with no expiry. Then abandoned carts lock stock forever.

Good: a time-limited reservation. Reserve decrements stock and creates a hold that expires in 10 minutes. Payment turns the hold into an order. A sweeper (a background job that scans for expired holds) releases them and puts the units back. Idempotency keys on reserve and order mean a double tap or a retry never creates two holds.

Great: holds plus fairness controls. Allow one reservation per account, enforced atomically in the same script as the decrement (SET NX bought:{user}). Admission tokens are signed and bound to the account, so they cannot be shared or replayed.

Basic bot defences run before the waiting room, not on the hot path. These include device checks, account age and a challenge for suspicious traffic. Payment webhooks are idempotent on order_id. A payment that arrives after its hold expired is refunded automatically.

Trade-off: shorter holds return stock faster but fail more slow payers. Longer holds are kinder but leave stock idle. Ten minutes is a reasonable middle for UPI, and the released units go to people still in the waiting room.

What breaks

  • The stock counter fails over and loses recent decrements. The counter now shows more stock than truly exists. The conditional update in the database refuses the extras, and a drift alert (counter versus database) fires.
  • The order writer falls behind: reservations wait in the queue, and shoppers see "confirming" for a few seconds longer. Consumer lag alerts fire. Add writers, since the queue is partitioned by SKU.
  • The waiting room is down: the gateway fails closed for the sale endpoints and shows "please wait". It does not let the full stampede hit reserve. The rest of the shop is unaffected.
  • The payment gateway is slow during the peak: holds may expire while payments are in flight. Extend holds for payments already started, and refund automatically if a payment lands after expiry.
  • The status JSON on the CDN is stale: shoppers see "available" for a second or two after the stock is gone. Reserve still answers sold out correctly, so only the display is affected.

What each level is expected to cover

Mid level: A Mid candidate should put the static page on a CDN and rate-limit at the gateway. They should use an atomic decrement or a conditional update, so stock never goes below zero. They should put a queue in front of order writes, and replicate the database.

Senior: A Senior candidate also estimates the spike of 83,000 a second against 5,000 units, and designs to reject 99.9% of requests cheaply. They add a waiting room that admits at a known rate. They separate the fast in-memory gate from the database guarantee, and reconcile the two. They design time-limited holds and their expiry, enforce one per account atomically, and isolate the sale from the rest of the shop.

Common mistakes

  • Read-then-write stock checks that race and oversell.
  • Letting every request reach the database row for the SKU.
  • Relying on autoscaling for a spike that lasts one minute.
  • Trusting the in-memory counter alone, with nothing to catch drift after a failover.
  • Holding stock with no expiry, or not holding it at all until payment.
  • Ignoring bots and fairness, so scripts buy most of the stock.

Follow-up questions

  • How would you sell 50 different products in the same sale, some with only 10 units?
  • A shopper's payment succeeds a minute after their hold expired. What happens?
  • How do you load-test this before the sale, and what number do you set the admission rate to?
  • How would you stop one person using 200 accounts?
  • What would you change to run the sale across two regions?

Lessons behind this answer