System Design #14: Design a Flash Sale System

SYSTEM DESIGN #14 · INTERVIEW GUIDE

Design a Flash Sale System

The moment a flash sale goes live, your servers face a traffic spike that makes Black Friday look calm — 100,000 users hitting buy simultaneously for a product with only 500 units in stock. Without careful engineering, you either oversell (shipping products you don’t have) or crush your database under a thundering herd. This system solves both problems: a Virtual Queue built on Redis ZADD admits users in controlled batches, a Lua atomic script decrements inventory without race conditions, and a Saga pattern coordinates the order-payment-reservation flow with compensating transactions for every failure mode. The CDN serves the product page to millions of waiting users without a single origin hit, and a Kafka-delayed TTL pipeline releases held inventory if payment fails. You will learn how Nike, Supreme, and PS5 drops are engineered to handle 10 million requests in the first 10 seconds.

Redis LuaVirtual QueueSaga PatternTemporalKafka

💡

The Gist — What Problem Are We Solving?

Opening one door to a stadium full of people — all at the same moment

A flash sale creates the hardest load pattern in software engineering: zero traffic, then 10,000 requests per second the instant the sale opens, then gradual decline. The system must absorb this spike, ensure nobody buys more units than exist, maintain fairness (don’t reward the fastest internet connection), and keep the rest of the platform running normally for everyone else.

💬

Think of it as a bouncer managing a massive crowd — checking IDs, letting people in fairly, making sure the venue never exceeds capacity, and never letting anyone sneak in twice.

Functional Requirements

These are the capabilities the system must deliver — what users and operators can actually do with it.

🛒
Purchase Flow

🛒Add to cart → checkout → payment → confirmation within 10-minute window

🔒
Inventory Control

🔒Never sell more units than available stock; atomic check-and-reserve

🚦
Virtual Queue

🚦Fairness mechanism at T=0; FIFO ordering; position shown to user


Reservation TTL

⏰Hold unit for 10 minutes during checkout; release on timeout

📊
Real-Time Inventory

📊Show live stock count to users; update within 1 second

Non-Functional Requirements

These define how well the system must perform — the quality attributes that separate a toy from a production system.

⚡ Peak Throughput

⚡10K req/sec at T=0; pre-scaled; not reactive auto-scale

🔒 Oversell

🔒Zero tolerance — atomic check-and-reserve via Redis Lua

⏱️ Inventory Freshness

⏱️Stock count updated within 1 second

🌐 CDN

🌐Product page served from CDN — zero origin hits at T=0

🛡️ Fairness

🛡️Virtual queue at T=0; FIFO by arrival timestamp

📊

Key Metrics — The Numbers That Define This System

The headline numbers to know cold — and be ready to explain how each one is achieved.

10K/sec
T=0 burst
1 sec
inventory TTL
10 min
checkout reservation
0
oversell tolerance
CDN
product page
🏗️

System Architecture Diagram

Full data flow from source to serving. Each layer scales independently.

Ingestion
Flash Sale Flow
CDN
product page

Rate Limiter

Virtual Queue
Redis sorted set

Purchase Service

Processing
Lua check-and-DECR
Redis inventory

Order Service
Postgres

Payment

Kafka delayed event
10min timeout

Storage
Waitlist notify

🗺️

End-to-End User Journey

Trace a single request end-to-end — the story interviewers want you to tell fluently.

1
5 min before sale

— Scheduler pre-warms all caches; product page pushed to CDN edge; purchase service pods pre-scaled to 10×

2
T=0 — sale opens

— 10,000+ users hit ‘buy’ simultaneously; CDN serves product page (zero origin); rate limiter queues purchase requests

3
Virtual queue

— Requests enter Redis sorted set (score=timestamp); FIFO token issued when user reaches front

4
Atomic reserve

— Lua script: IF inventory > 0 THEN DECR inventory; return success ELSE return sold_out; — atomic, no race condition

5
Checkout started

— 10-minute countdown timer shown; order record created with status=RESERVED in Postgres

6
Payment completes

— Order status → CONFIRMED; Kafka event fired; confirmation email sent

🔭

High-Level Design — Component Breakdown

Core components — each with a single, well-defined responsibility. The key architectural insight: each layer scales independently, and failure in one component is isolated from the rest.

1
CDN
2
Queue
3
Purchase
4
Inventory
5
Payment
6
TTL Manager
1 — CDN

Content Delivery Network serving static assets and media from 200+ PoPs. Cache-Control: immutable for media segments. Origin shield reduces origin load by 99%. Multi-CDN failover for resilience.

2 — Queue

Handles responsibilities for the Queue layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

3 — Purchase

Handles responsibilities for the Purchase layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

4 — Inventory

Handles responsibilities for the Inventory layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

5 — Payment

Handles responsibilities for the Payment layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

6 — TTL Manager

Handles responsibilities for the TTL Manager layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

🔬

Low-Level Design — Deep Dives

Deep dives worth explaining in detail in any senior engineering interview. For each: know the data structure, the algorithm, the why, and the trade-off you made.

1 — Virtual Queue Manager
Redis ZADD · FIFO Fairness

Queue implemented as Redis Sorted Set: ZADD queue:{sku_id} arrival_ms user_id. Opening window (first 100ms): arrivals clamped to T+100 and shuffled (random secondary sort) for fairness. Queue position served via WebSocket every 5 seconds. Admission rate: 500 users/second released to checkout funnel. Queue status endpoint: GET /queue/{sku_id}/{user_id} → {position, estimated_wait_seconds}. Queue capacity cap: len(queue) > inventory × 3 → redirect new arrivals to sold-out/waitlist page.

def join_queue(sku_id, user_id):
ts = max(time.time_ns()//1e6, SALE_START_MS + 100)
redis.zadd(f’queue:{sku_id}’, {user_id: ts})
pos = redis.zrank(f’queue:{sku_id}’, user_id)
return {‘position’: pos, ‘wait_sec’: pos / ADMIT_RATE}

2 — Inventory Atomic Decrement
Lua Script · Zero Oversell

Inventory decrement is the only place inventory count changes. Lua script loaded at startup (SCRIPT LOAD), called by SHA hash: check stock > 0, decrement, return success/failure in single atomic operation. Redis single-threaded execution makes this lock-free. Inventory initialised from warehouse system 60 seconds before sale with a configurable safety buffer (default 0% — exact count). Separate Redis key per SKU variant (size/colour).

— Lua script (loaded at startup)
local inv = redis.call(‘GET’, KEYS[1])
if not inv or tonumber(inv) <= 0 then return {0, 'sold_out'} end redis.call('DECR', KEYS[1]) return {1, 'reserved'}

3 — Saga Orchestrator
Kafka · Compensating Transactions

Booking Saga: RESERVE_INVENTORY → CREATE_ORDER → CHARGE_PAYMENT → CONFIRM_ORDER. Each step publishes a Kafka event. Saga coordinator (Temporal workflow) handles failures: payment failure → compensate with RELEASE_INVENTORY (INCR in Redis). Timeout (15min): delayed Kafka message triggers CANCEL_ORDER + RELEASE_INVENTORY. Idempotency keys on all steps prevent double-execution on retry. Full saga state in Postgres with status transitions audited.

@workflow.defn
class BookingWorkflow:
@workflow.run
async def run(self, booking_id):
try:
await workflow.execute_activity(reserve_inventory)
await workflow.execute_activity(create_order)
await workflow.execute_activity(charge_payment)
await workflow.execute_activity(confirm_order)
except PaymentFailed:
await workflow.execute_activity(release_inventory)

4 — Waitlist & TTL Release
ZPOPMIN · Kafka Delayed

Waitlist: Redis Sorted Set waitlist:{sku_id} with score=join_timestamp. On inventory release (payment failure or TTL expiry): Kafka-delayed message triggers ZPOPMIN to dequeue next waitlisted user. User receives 10-minute exclusive purchase window via WebSocket + push notification. If window expires unused: ZPOPMIN again. Concurrent expiry guard: Lua script verifies ZPOPMIN’d user_id matches the one being notified before granting inventory.

def release_to_waitlist(sku_id):
user_id = redis.zpopmin(f’waitlist:{sku_id}’, 1)
if not user_id: return
redis.setex(f’window:{sku_id}:{user_id}’, 600, ‘1’) # 10min
notify_user(user_id, sku_id)
kafka.publish(‘waitlist_expiry’, {‘sku’:sku_id,’user’:user_id}, delay_s=600)

5 — CDN Caching Strategy
99% Origin Offload

Product page: fully static HTML cached at CDN with 5-minute TTL. Dynamic elements (price, inventory status) fetched via client-side JavaScript from /api/status endpoint — CDN caches this for 10 seconds. On sale start, CDN cache for /api/status is purged via API. Sale start time is precise to the second — CDN origin header Cache-Control: max-age=10, stale-while-revalidate=5. Origin shield (CDN PoP closest to origin) reduces direct origin hits by 99% even during traffic spikes.

# Nginx origin cache headers
response.headers[‘Cache-Control’] = \
‘public, max-age=10, stale-while-revalidate=5′
# Programmatic purge at T-5 seconds
cdn.purge(tag=’product-status’, wait=True)

⚖️

Trade-offs & Decision Log

Every senior interview comes down to these decisions. Know the exact trade-off, the reasoning, and the specific numbers that justify each choice.

⚖️ Virtual Queue vs Direct Purchase Funnel


Virtual Queue (Redis ZADD) ✅ Chosen
  • Smooths traffic spike — admits users in controlled batches of 500/sec
  • Users see fair position number — reduces bounce rate vs error page
  • Inventory held only for users actively in funnel
  • Redis ZADD score = arrival timestamp for FIFO fairness

Direct to checkout (no queue)
  • Simpler user experience — no waiting
  • Database hammered by 100K concurrent checkout attempts
  • Oversell probability near 100% without Lua atomic scripts
  • Server response time degrades to 30s+ under load

💡

Decision: Virtual queue for items with >10K expected demand; direct purchase for normal inventory; Lua atomic DECR prevents oversell in both cases

⚖️ Optimistic vs Pessimistic Locking for Inventory


Redis Lua Atomic Script ✅ Chosen
  • Single atomic DECR+CHECK in Lua — no lock acquisition needed
  • Microsecond execution — 50K operations/sec per Redis node
  • No deadlock risk — Lua script is non-preemptable
  • Requires Redis cluster — not suitable for Postgres-only stacks

Database row-level lock (SELECT FOR UPDATE)
  • Works with existing Postgres schema
  • Deadlock risk under high contention
  • 1000× slower than Redis Lua under flash-sale load
  • Connection pool exhaustion at 100K concurrent buyers

💡

Decision: Redis Lua for inventory decrement; Postgres with SELECT FOR UPDATE for order record creation; two-phase commit for consistency

🎯

Interview Questions — Answered
The exact questions interviewers ask — with production-grade answers

Q1
How does the virtual queue ensure fairness (first-come, first-served)?

Redis ZADD uses arrival timestamp (Unix millisecond) as the score: ZADD virtual_queue timestamp user_id. ZPOPMIN(N) dequeues exactly N users in timestamp order — strict FIFO. To prevent timestamp gaming (users submitting milliseconds before sale start), the queue only opens at exactly T=0 and all requests arriving in the first 100ms are treated as simultaneous (scores clamped to T+100ms) and shuffled randomly within that window. Queue position is served to users via WebSocket every 5 seconds. Queue capacity check: if queue length > inventory × 3, new arrivals receive a ‘sold out — join waitlist’ message rather than entering the queue.

Q2
How does the Lua script prevent oversell atomically?

Redis Lua script executed atomically (Redis is single-threaded; Lua scripts are non-preemptable): local remaining = redis.call(‘GET’, ‘inventory:’ .. sku_id); if remaining == false or tonumber(remaining) <= 0 then return 0 end; redis.call('DECR', 'inventory:' .. sku_id); return 1. Return value 1 = reservation successful; 0 = sold out. This is the only place inventory is decremented — no other code path touches inventory count. The script is loaded with SCRIPT LOAD on startup and called by SHA hash for <1ms execution. Pre-loaded inventory count is set from the warehouse system 1 minute before sale start with a 10% buffer for errors.

Q3
What happens if a user reserves inventory but payment fails?

Saga compensating transaction: (1) Inventory decremented (Lua script). (2) Order record created in Postgres with status=RESERVED and TTL=15 minutes. (3) Payment initiated (Stripe/Adyen). (4) If payment succeeds: status=CONFIRMED. (5) If payment fails: Saga compensation triggers inventory re-increment (INCR in Redis Lua) and order status=PAYMENT_FAILED. (6) Re-incremented inventory is immediately available via a Kafka message consumed by the virtual queue, which notifies the next user in queue. A Kafka-delayed message (publish at T+15min) handles the case where payment is abandoned without explicit failure — TTL expiry triggers automatic compensation.

System Design Series · Every Tuesday & Thursday

Level up your system design interviews

Each post covers Gist, Functional & Non-Functional Requirements, Key Metrics, System Diagram, User Journey, HLD, LLD, and Trade-offs & FAQs.

Subscribe to never miss a post →


Categories: System Design

Tags: , , , , , ,

Leave a Reply

Discover more from Cloud Wizard Inc.

Subscribe now to keep reading and get access to the full archive.

Continue reading