Lesson 437 min read

Design a Flash Sale System

April 22, 2026

// TL;DR

A flash sale is a contention and traffic-spike problem. Protect correctness with a single atomic decrement (Redis DECR or a conditional DB update) so you never oversell, and protect the backend by shedding load as early as possible — a static CDN page, rate limiting, and a virtual queue that admits only a trickle. Use reserve-then-pay with a TTL so a held unit is released if the buyer does not complete checkout.

Design a Flash Sale System

At 12:00:00 sharp, a retailer drops 10,000 units of a hyped sneaker at half price. Three million people have the page open, fingers on the buy button. In the first second you may see several million requests, and you must sell exactly 10,000 units — not 10,001, not 9,998 — while keeping the site up for everyone else.

This is one of the most revealing system design questions because it forces two hard problems at once: extreme write contention on a tiny shared resource (the stock count) and a massive, instantaneous traffic spike. A naive “read stock, if > 0 then decrement and create order” will oversell badly and melt the database. Let us design something that does not.

Understanding the Problem

Functional Requirements

  • Buy a unit of a sale item while stock remains
  • Never oversell: sell at most the exact inventory count
  • Reserve then pay: holding a unit gives the buyer a short window to complete payment
  • Show stock state: “available” vs “sold out”
  • Release abandoned holds so the unit returns to the pool if payment never completes

Non-Functional Requirements

  • Correctness under extreme concurrency: no overselling, ever — this is non-negotiable
  • Survive the spike: millions of requests in the first seconds must not take down the system
  • Low latency for the lucky buyers who get through
  • Fairness: roughly first-come-first-served; resist bots and scripts
  • Graceful rejection: tell the 99.7% who missed out quickly and cheaply

Scale Estimation

Say 3,000,000 users and 10,000 units. If each user retries a few times in the first few seconds, the peak can be several million requests per second against a resource that can satisfy only 10,000 of them. The defining number is the ratio: ~99.7% of all traffic must be rejected, and the sooner we reject it, the cheaper it is. The database should never see more than a small multiple of the 10,000 real purchases.

Core Entities and APIs

Data Model

interface SaleItem {
  itemId: string;
  name: string;
  totalStock: number;      // e.g. 10000 — immutable for the sale
  saleStartAt: number;     // unix ts
  pricePaise: number;
}

interface Hold {
  holdId: string;
  itemId: string;
  userId: string;
  createdAt: number;
  expiresAt: number;       // createdAt + TTL (e.g. 2 minutes)
  status: 'held' | 'confirmed' | 'expired';
}

interface Order {
  orderId: string;
  holdId: string;
  userId: string;
  itemId: string;
  status: 'pending_payment' | 'paid' | 'cancelled';
}

API Design

POST /sales/{itemId}/reserve     — attempt to grab a unit (returns a holdId or 'sold_out')
POST /sales/holds/{holdId}/pay   — pay for a held unit; confirms the order
GET  /sales/{itemId}             — item + current availability (served from cache/CDN)

The reserve call is the entire ballgame. Everything else is a normal e-commerce flow.

High-Level Design

Flash sale architecture — admission control in front of a single atomic stock decrement so the system never oversells

Two independent jobs, layered:

  1. Protect correctness with one atomic operation on the stock counter. This is what guarantees we never sell unit 10,001.
  2. Protect the backend by shedding as much traffic as possible before it ever reaches that counter.

The request path, from outermost to innermost:

  • Edge / CDN serves the product page and a static “sale is live” asset. Reads never hit origin.
  • Rate limiter + bot defense at the gateway drops obvious abuse (per-IP, per-user, CAPTCHA/device checks).
  • Admission control (virtual queue) admits users at a controlled rate — only a trickle is let through to actually attempt a purchase.
  • Reservation service performs the single atomic decrement against a Redis counter holding the live stock.
  • Order + payment runs asynchronously for the small number of successful holders.

Deep Dive: Never Oversell

The heart of the problem. With millions of concurrent attempts, any “read-modify-write” across two commands races and oversells — the exact bug from the rate-limiter lesson, but with money on the line. The fix is to make the check-and-decrement a single atomic operation.

Option A: Atomic counter in Redis

Seed a key with the stock count and let Redis do the decrement atomically:

# Seeded once before the sale: redis.set("stock:item42", 10000)

def reserve(item_id, user_id):
    remaining = redis.decr(f"stock:{item_id}")   # atomic
    if remaining < 0:
        redis.incr(f"stock:{item_id}")           # undo the overshoot
        return "sold_out"
    hold_id = create_hold(item_id, user_id, ttl=120)
    return hold_id

DECR returns the value after decrementing, and it is atomic across every client and every app server. The first 10,000 callers see remaining >= 0 and win; everyone after sees a negative value, which we immediately correct with an INCR. No lock, no race, O(1). For a cleaner version, a tiny Lua script can check-and-decrement without the undo:

-- KEYS[1] = stock key
local n = tonumber(redis.call('GET', KEYS[1]) or '0')
if n <= 0 then return -1 end
return redis.call('DECR', KEYS[1])

Option B: Conditional update in the database

If you prefer the database as the source of truth, use a guarded update and trust the row lock:

UPDATE sale_item
SET    stock = stock - 1
WHERE  item_id = 42 AND stock > 0;
-- rows affected = 1 → reserved; 0 → sold out

stock > 0 in the WHERE clause makes the decrement conditional and atomic. It works, but a single hot row serialises every write, and at millions of attempts per second the database becomes the bottleneck. Redis absorbs that rate far more comfortably; most designs decrement in Redis and reconcile to the database asynchronously.

Hot-key scaling

Even Redis has a ceiling on a single key. If one item is hot enough to saturate it, split the stock across N sub-counters (e.g. 10 keys of 1,000 each). A request hashes to one sub-counter and decrements it; when a sub-counter hits zero, it falls back to another. This spreads the contention at the cost of slightly more complex “is it truly sold out?” logic. This is the same hot-key mitigation from the Scaling Pitfalls lesson, applied to a counter.

Deep Dive: Shed Load Early

Correctness is solved, but the backend still cannot touch millions of requests per second. The rule: reject as far out as possible, because every layer inward is more expensive.

Requests shed at each stage — edge/CDN, rate limiter, queue, atomic stock, then async order — so most never reach the database
  • Edge / CDN: the product page, images, and countdown are fully static and cached. The vast majority of load — people just watching the page — never reaches your servers.
  • Rate limiting + bot defense: cap attempts per user/IP and apply device fingerprinting or a lightweight challenge. Scalpers running scripts are a large fraction of flash-sale traffic.
  • Virtual waiting room: this is the key move. Instead of letting three million people hit reserve at once, place them in a queue (a token or a position issued at the edge) and admit them at a controlled rate — a few thousand per second. The reservation service then handles a sane, steady load, and the stock sells out in an orderly way. Users see a “you’re in line, position 48,210” page rather than errors.
  • Fast rejection: once stock hits zero, flip a cached sold_out flag at the edge so new attempts are rejected without touching Redis at all.

The mental model: a funnel that is wide at the top (cheap static edge) and narrow at the bottom (the one atomic decrement), shedding an order of magnitude of traffic at each stage.

Deep Dive: Reserve-Then-Pay and Fairness

Decrementing stock is not the same as completing a sale. Buyers still have to pay, and payment can fail or be abandoned. If a successful decrement immediately “sold” the unit, abandoned carts would permanently lose inventory.

Reserve-then-pay with a TTL solves this. A successful reserve creates a Hold that expires in, say, two minutes:

def expire_holds():  # background sweeper, or rely on a Redis TTL + keyspace event
    for hold in holds_expired_now():
        if hold.status == 'held':
            hold.status = 'expired'
            redis.incr(f"stock:{hold.item_id}")   # return the unit to the pool

The buyer must call pay before the hold expires; on success the hold becomes confirmed and the order is finalised. On expiry, the unit returns to the pool and someone in the waiting room can grab it. This is a small saga: reserve → pay → confirm, with “release the hold” as the compensating action (see the Distributed Transactions lesson).

Fairness and abuse: a virtual queue gives rough first-come-first-served ordering. Pair it with per-user purchase limits (enforced on the same atomic path), bot defenses, and idempotency keys on reserve/pay so a buyer frantically retrying does not consume two units or create two orders.

Key Takeaways

  1. One atomic operation guarantees no overselling. A Redis DECR (or a WHERE stock > 0 conditional update) is the single primitive that stays correct under millions of concurrent attempts. Never read-then-write across two commands.
  2. Shed load as early as possible. Static CDN page → rate limiting + bot defense → virtual waiting room → atomic decrement. Each layer rejects an order of magnitude of traffic so the database sees only a small multiple of the real sales.
  3. A virtual queue tames the spike. Admitting buyers at a controlled rate converts an unservable burst into a steady, orderly stream and gives users a clear “you’re in line” experience instead of errors.
  4. Reserve-then-pay with a TTL separates grabbing a unit from completing the purchase, and returns abandoned holds to the pool automatically.
  5. Split a hot counter into sub-counters when a single key saturates — the same hot-key mitigation used elsewhere, applied to inventory.
  6. Make everything idempotent. Panicked users retry constantly; idempotency keys on reserve and pay prevent double-spend and duplicate orders. This question is really about contention — see the Dealing with Contention lesson for the general pattern.