- A flash sale is a funnel: millions of page views → hundreds of thousands of "Buy" clicks → tens of thousands of units. Design each layer to absorb its own load and pass only what the next layer can handle.
- Serve product and countdown pages statically from the CDN; pre-warm caches and pre-scale everything before T-0, because autoscaling is too slow for a vertical spike.
- Put a virtual waiting room in front of checkout and admit users at the rate checkout can sustain, with short-lived signed admission tokens.
- Prevent overselling with an atomic, conditional decrement: a Redis Lua script as the fast gate, a SQL
WHERE available >= qtyupdate (split across bucket rows) as the durable guard, and TTL reservations that release abandoned stock. - Fight bots with per-account limits, verified pre-registration, rate limiting and CAPTCHA at entry. Shed non-critical features with flags so checkout survives.
Most system design problems are about steady-state scale. A flash sale is about a cliff: traffic goes from normal to 50–100x in the second the clock hits 12:00, almost all of it competing for a tiny amount of stock, and almost all of it destined to fail. The core skill is turning that chaos into an orderly, fair queue in front of a small, correct inventory system. This guide designs the "Big Billion Days" phone sale end to end, from the CDN down to the row lock.
The Shape of a Flash Sale
Think of the sale as a timeline, because the design has components active at different times:
| Time | What users do | What the system must do |
|---|---|---|
| T-7 days to T-1 hour | Register interest, add address and payment method, save cards | Pre-registration, account verification, capacity tests |
| T-15 min | Open the product page, watch the countdown, refresh repeatedly | Serve static pages from the CDN; absorb refresh storms |
| T-0 to T+60 s | Everyone clicks "Buy Now" | Admit fairly, reserve stock atomically, reject the rest fast |
| T+1 to T+15 min | Winners pay; losers retry hoping for released stock | Process payments, expire abandoned reservations, re-release stock |
| T+15 min onward | "Sold out" | Short-circuit traffic at the edge; reconcile orders vs stock |
The single most useful framing for the interview: 99%+ of requests at T-0 should fail, so make failing cheap. A "sorry, sold out" served from a CDN costs nothing; one that went through five services and a database row lock to find out is what takes systems down.
Requirements
Functional
- Show product page and countdown; open purchasing at exactly T-0.
- Let users reserve a unit, then pay within a time limit.
- Enforce a per-user purchase limit (say 1 unit per account).
- Release unpaid reservations back to stock.
- Show a clear status: in queue (position), reserved (timer), sold out.
Non-functional
- Never oversell. Selling unit 50,001 means cancelling an order, which is a worse experience than not getting one.
- Don't undersell either: abandoned reservations must return to stock.
- Stay up under the spike; the rest of the site (other categories) must keep working.
- Fairness: real users should have a reasonable chance compared to bots and scripts.
- Low latency for the reserve step (a user should know within a second or two whether they got it).
Traffic Estimates
Assumptions (illustrative, not company data):
- 10 million users online for the noon phone deal; stock: 50,000 units.
- In the last minute before T-0, each user refreshes every ~5 s → 10M / 5 = 2M page requests/s.
- At T-0, say 40% click Buy within the first 10 s → 4M clicks / 10 s = 400K buy requests/s, plus client retries that can double or triple it.
- Successful reservations: 50,000, almost all within the first few seconds → ~10K–25K successful reserves/s for a moment, then nothing.
- Payment: 50K payments over ~10 minutes ≈ ~80 payments/s average with an early peak of a few hundred per second.
Read that last number carefully: of 4 million buy attempts, about 1 in 80 can succeed. Checkout, payment and order systems only need to handle the winners. Everything upstream exists to reject the other 79 quickly and fairly.
Architecture: A Funnel with Gates
Each gate reduces load by an order of magnitude. The services behind gate 1 never see more traffic than they were sized for, regardless of how many people show up. That is the whole trick.
Gate 0: Static Content, Cache Warming and Pre-Scaling
- Make the product page static. Price, images, specs and description are fixed for the sale; render them once, publish to the CDN with a long TTL. Personalised bits (cart count, user name) load via a separate small API call that can fail without breaking the page.
- Countdown in the client using server time fetched once (not a per-second poll). The "Buy" button becomes active at T-0 based on that offset, and the server enforces T-0 independently, because clients can be tampered with.
- Sale state as a tiny edge-cached JSON (
{"state":"live","sold_out":false}, TTL 1–2 s). When stock runs out, flip it; within seconds every client stops sending buy requests. - Pre-warm caches: load SKU details, price, stock counters, user addresses and saved payment tokens for pre-registered users into Redis before T-0. A cold cache at T-0 means a thundering herd on the database (the classic cache stampede).
- Pre-scale: scale out stateless tiers to the forecast peak an hour before; warm connection pools; pre-split hot database and cache partitions. Reactive autoscaling takes minutes; the spike takes one second.
- Load test the real thing: full-path tests with production-like data, including the failure paths (payment gateway slow, Redis node failover).
Gate 1: Queue-Based Admission (Virtual Waiting Room)
If 400K requests/s hit the reservation service directly, most get timeouts, users retry, and the retry storm makes it worse. A waiting room converts the burst into a steady, controlled stream.
- Enter. At T-0 the "Buy" click calls
POST /waiting-room/{sale}/join. The service does an atomicINCR sale:{id}:seqin Redis and returns a signed ticket with the user's position. - Randomise the first window (optional). Everyone who joined between T-0 and T+10 s is effectively simultaneous; network latency, not intent, decided their order. Shuffle positions within that window so being 40 ms closer to the data centre doesn't win. This is a product decision, mention both options.
- Admit at a fixed rate. A counter
sale:{id}:admitted_uptoadvances by R every second (R = what checkout can handle, say 10K/s). Clients pollGET /statusevery few seconds (served from cache) or listen for a push. - Issue an admission token when
position ≤ admitted_upto: a JWT/HMAC token with user ID, sale ID and expiry (2 minutes). The reservation API rejects any request without a valid, unused token. - Stop early. When remaining stock + expected releases is clearly less than the number of admitted-but-undecided users, stop admitting and tell queued users the item is sold out (with a "we'll notify you if stock returns" option).
This same pattern is used for railway Tatkal bookings and concert ticket sales; see IRCTC Tatkal design for a version with a seat-allocation twist.
Gate 2: Inventory Reservation
Separate reserving a unit from buying it. A reservation holds one unit for a user for a fixed time (say 10 minutes) while they pay. Stock has three buckets:
The Redis reserve script
All keys for one SKU share a Redis Cluster hash tag ({sku:123}) so the script runs atomically on one shard. Redis executes a Lua script without interleaving other commands, so check-then-decrement is safe.
import time, uuid, redis
r = redis.Redis(decode_responses=True)
RESERVE = r.register_script("""
-- KEYS[1] stock, KEYS[2] per-user marker, KEYS[3] reservations zset
-- ARGV[1] qty, ARGV[2] reservation_id, ARGV[3] expires_at (epoch s)
if redis.call('EXISTS', KEYS[2]) == 1 then
return -2 -- user already holds/bought one
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local qty = tonumber(ARGV[1])
if stock < qty then
return -1 -- sold out
end
redis.call('DECRBY', KEYS[1], qty)
redis.call('SET', KEYS[2], ARGV[2], 'EX', 86400)
redis.call('ZADD', KEYS[3], ARGV[3], ARGV[2])
return stock - qty -- remaining
""")
RELEASE = r.register_script("""
-- KEYS[1] stock, KEYS[2] per-user marker, KEYS[3] reservations zset
-- ARGV[1] reservation_id, ARGV[2] qty
if redis.call('ZREM', KEYS[3], ARGV[1]) == 1 then
redis.call('INCRBY', KEYS[1], tonumber(ARGV[2]))
if redis.call('GET', KEYS[2]) == ARGV[1] then
redis.call('DEL', KEYS[2])
end
return 1
end
return 0 -- already confirmed or released
""")
def keys(sku, user):
tag = "{sku:%s}" % sku
return [f"{tag}:stock", f"{tag}:user:{user}", f"{tag}:resv"]
def reserve(sku, user, hold_seconds=600):
rid = str(uuid.uuid4())
res = RESERVE(keys=keys(sku, user), args=[1, rid, int(time.time()) + hold_seconds])
if res == -1: return {"status": "sold_out"}
if res == -2: return {"status": "limit_reached"}
publish("reservation_created", sku=sku, user=user, rid=rid) # to Kafka -> DB
return {"status": "reserved", "reservation_id": rid, "hold_seconds": hold_seconds}
def release_expired(sku, now=None):
now = now or int(time.time())
tag = "{sku:%s}" % sku
for rid in r.zrangebyscore(f"{tag}:resv", "-inf", now, start=0, num=500):
user = lookup_user(rid) # from the reservations table
if RELEASE(keys=keys(sku, user), args=[rid, 1]) == 1:
publish("reservation_expired", sku=sku, rid=rid)
On payment success, the confirm path removes the reservation from the ZSET (ZREM) so the expiry worker can't release it, and records the sale. The ZREM return value is the arbiter: whichever of "confirm" or "expire" removes the entry first wins, and the other becomes a no-op. If expiry wins a race against a payment that succeeded at the very last second, the order path must refund or, better, try to take another unit; give a small grace period on expiry to make that rare.
Gate 3: Oversell Prevention You Can Trust
Redis is fast but its replication is asynchronous. If the primary fails right after acknowledging 300 decrements that hadn't reached the replica, the promoted replica has 300 extra units. That's an oversell. So the in-memory counter is a filter, and the database is the guard.
Durable conditional decrement
-- Stock split into N bucket rows to avoid a single hot row
CREATE TABLE inventory_bucket (
sku_id BIGINT,
bucket SMALLINT,
available INT NOT NULL CHECK (available >= 0),
PRIMARY KEY (sku_id, bucket)
);
-- 50,000 units across 20 buckets = 2,500 each
-- Reserve one unit from a randomly chosen bucket
UPDATE inventory_bucket
SET available = available - 1
WHERE sku_id = 123 AND bucket = :b AND available >= 1;
-- 1 row updated => success; insert reservation row in the same transaction
-- 0 rows updated => bucket empty; try another bucket (or report sold out
-- once every bucket has returned 0)
Why buckets? A single row is serialised by its row lock: every transaction waits for the previous one to commit. At a few milliseconds per commit, one row tops out at a few hundred to low thousands of updates per second. Twenty bucket rows give roughly 20x the throughput, and the CHECK (available >= 0) constraint plus WHERE available >= 1 make negative stock impossible regardless of concurrency.
Putting the layers together
- Redis Lua says yes (≈ 50K yeses total, everyone else gets "sold out" from memory).
- The reservation service performs the SQL bucket decrement and inserts the reservation row in one transaction. Only ~50K such transactions happen, spread over a few seconds: comfortable for a sharded SQL cluster.
- If SQL says no (Redis drifted after a failover), return "sold out" and correct the Redis counter.
- A reconciler compares Redis stock, SQL buckets, and reservation/order counts every few seconds during the sale.
Alternatives worth naming: pre-generated stock tokens (push 50,000 unit IDs into a queue/list; a reserve is an atomic pop, so it can't go below zero by construction), and pessimistic SELECT ... FOR UPDATE (correct but serialises everything on one lock). The token approach is elegant for small, fixed stock; the bucket approach scales to large catalogues with many SKUs.
Checkout and Payment
- Isolate checkout: dedicated instances, connection pools and database shards for the sale SKUs so a meltdown in browsing can't starve checkout, and vice versa (bulkhead pattern).
- One-tap payment: pre-registered users have saved addresses and payment methods (tokenised cards, UPI handles), so the reserved window isn't wasted typing addresses.
- Idempotent order creation: the reservation ID is the idempotency key for the order. Double-tapping "Pay" creates one order. The detailed payment state machine is in payment system design.
- Pending payments: a UPI payment can be pending for minutes. Extend the reservation while the payment is in a pending state rather than releasing stock under a user who has actually paid.
- Payment gateway capacity: coordinate with gateways/banks beforehand; spread across multiple gateways; the admission rate must respect the payment provider's limits too.
Bot Protection and Fairness
Resellers run scripts that click at exactly T-0 from hundreds of accounts. Without defences, they win. Layer them:
| Layer | Technique | Stops |
|---|---|---|
| Before the sale | Mandatory pre-registration; account age and phone/OTP verification; KYC-lite checks | Freshly created bot accounts |
| Edge | WAF rules, IP reputation, rate limit per IP/ASN, TLS/device fingerprinting | Floods, data-centre scripts |
| Waiting room entry | CAPTCHA or proof-of-work challenge, shown before T-0 so it doesn't add latency at T-0 | Headless automation at scale |
| Admission | Randomised order within the opening window | Latency-optimised scripts |
| Reservation | One unit per account, device, address and payment instrument (enforced atomically) | Multi-account hoarding |
| Post-sale | Cancel orders linked by shared address/payment/device graph | Rings that slipped through |
Per-user limits must be enforced in the same atomic step as the stock decrement (the per-user key in the Lua script); checking in a separate call lets a user's ten parallel requests all pass the check before any of them records the purchase.
Graceful Degradation: What to Turn Off
Every dependency on the sale path is a liability. Decide in advance, behind feature flags, what to switch off when pressure rises:
- Personalisation and recommendations ("customers also bought") → static lists.
- Reviews, Q&A, seller details → served stale from cache or hidden.
- Real-time stock counts ("only 312 left!") → coarse states ("selling fast", "sold out") updated every few seconds.
- Non-critical writes (analytics events, wishlist updates) → buffered on the client or dropped.
- Coupons and complex pricing → fixed sale price only, computed beforehand.
- Last resort: close the waiting room entrance and show a "high demand, you'll be notified" page from the CDN. A controlled "please wait" beats an uncontrolled 5xx page.
Use circuit breakers on every downstream call with sensible fallbacks, and make client retry behaviour sane: exponential backoff with jitter, a cap on retries, and the server returning Retry-After. A million clients each retrying three times in the same second is a self-inflicted DDoS.
Sale-Day Runbook
Prepare
Load tests at 2x forecast, game days for Redis/DB failover, payment gateway coordination, pre-registration opens.
Warm
Scale out, warm caches and pools, publish static pages, load stock into Redis and SQL buckets, freeze deploys.
Watch
Dashboards: admission rate, reserve success/fail, Redis vs SQL stock drift, payment success, error rates, p99 latency.
Reconcile
Sold + reserved + available = initial stock; cancel fraud; re-release expired units; post-mortem.
Interview Angle
What interviewers listen for
- You quantify the funnel and say "most requests should fail cheaply".
- You separate reserve from buy and handle expiry.
- You make stock decrement atomic and conditional, and you know why Redis alone isn't a durable guarantee.
- You address the hot row (buckets or token queue).
- You bring up bots and fairness without being prompted.
Follow-ups to rehearse
- "Redis primary dies at T+3 s. What happens?" Replica promoted, possibly missing recent decrements; SQL guard prevents oversell; reconciler fixes the Redis counter; some users see "sold out" a bit later than ideal.
- "How do you show queue position to 4M users?" Position is computed once at join; status polls hit a cached
admitted_uptovalue, so it's one cheap read per poll, served at the edge if needed. - "Multiple SKUs (colours/storage variants)?" Each SKU has its own counters and hash tag; the waiting room can be per sale event rather than per SKU.
- "How would you do this for movie seats?" Seats are distinct, not fungible: reserve specific seat IDs with per-seat locks, as in BookMyShow design.
Common mistakes
- "Autoscaling will handle it."
SELECT stockthenUPDATE stock = stock - 1in two steps (classic race, oversells).- Using a distributed lock around the whole checkout per SKU (serialises the sale to one user at a time).
- No reservation expiry, so abandoned carts keep stock hostage until the sale is over.
- Letting the flash sale share the database pool with the rest of the site.
The flash sale combines rate limiting, caching, atomic counters and payments under extreme concurrency, which is why it's a favourite at Indian e-commerce companies. Practise drawing the funnel and the three stock states; they anchor every follow-up. For the caching layer specifically, Redis data structures and use cases and distributed cache design go deeper.
Frequently Asked Questions
Prepare for High-Scale E-commerce Design Rounds
Prepflix's system design course breaks down Flipkart, Swiggy and IRCTC-style traffic spikes with mock interviews and feedback.
Explore the Prepflix Course →