Build a Flash Sale System for 500K Concurrent Checkouts


scalability distributed-systems reliability

System Design Deep Dive

Flash Sale: 500K Concurrent Checkouts

Selling out 10,000 items in seconds without overselling a single unit across 500K simultaneous buyers

⏱ 14 min read 📐 Advanced 🏗️ Scalability

Think of a concert ticket office on opening day: one window, thousands of people queued around the block, and exactly 1,000 seats available. The clerk must move fast enough that nobody waits forever, accurate enough that seat 1001 is never sold, and fair enough that the person who arrived first gets served first. Oversell by even one ticket and you have a furious customer at the door with no seat. The problem is making sure you never sell seat 1001 - at any scale.

Flash sales are the digital equivalent. When a brand drops a limited-edition product at noon, 500,000 users all click “Buy Now” simultaneously. The inventory is 10,000 units. Every component in the system - the application servers, the cache, the database - gets a wall of traffic that arrives in the same second and drops off just as suddenly. Your system must absorb the spike without overselling a single unit, without a thundering herd collapsing your database, and without making users wait so long they give up.

The tension is three-way: speed (checkout must feel instant, or users abandon), correctness (no overselling - ever, even under partial failure), and fairness (the person who clicked first should get the item, not the person whose request happened to land on a lightly-loaded pod). These three goals pull against each other. The fastest approach - a simple database decrement - collapses under concurrent load. The most correct approach - serialized global locking - is too slow. The fairest approach - a sorted queue - adds latency. The design must find the point where all three are satisfied simultaneously.

Requirements and Constraints

Functional Requirements

  • Handle burst traffic at the moment of sale start, with requests arriving at 50,000+ per second
  • Enforce strict inventory limits: if 10,000 units are available, exactly 10,000 orders may be confirmed - no more
  • Confirm every successfully placed order durably so it can be fulfilled, even if the confirming service crashes
  • Return a clear “sold out” response to users who arrive after inventory is exhausted
  • Allow users to retry a failed checkout without creating duplicate orders

Non-Functional Requirements

  • 500,000 concurrent users at peak, with the first 10 seconds being the most intense
  • Checkout response latency under 500ms at p99 during peak burst
  • Zero oversells: inventory correctness is the hard constraint, not a soft target
  • 99.99% availability for the checkout endpoint during the sale window
  • Graceful degradation: when inventory is exhausted, the system returns fast “sold out” responses rather than queueing new requests

Constraints and Assumptions

  • Inventory count is the absolute source of truth; the cache counter must be reconciled against it
  • Payment processing is asynchronous: an order is confirmed before payment is captured, with fulfillment gated on payment success
  • Users are authenticated before reaching the checkout flow; auth is not in scope
  • A user may only purchase one unit per sale event (per-user rate limiting enforced upstream)

High-Level Architecture

The system has seven major layers. The Load Balancer absorbs the burst and enforces per-IP rate limits. The Queue Gateway (virtual waiting room) is the admission control layer that converts a simultaneous spike into a manageable stream. The Checkout Service drives the checkout state machine. The Inventory Service wraps the Redis atomic counter and the reservation table. The Order Service creates durable order records with idempotency guarantees. The Payment Service handles async payment capture. The Cache Layer (Redis) is the primary inventory counter, backed by the database as the source of truth.

Flash sale system architecture showing Load Balancer, Queue Gateway virtual waiting room, Checkout Service, Inventory Service with Redis, Order Service with database, and Payment Service

The key architectural decision is the placement of the Queue Gateway before the Checkout Service. Without it, 500K concurrent requests hit the Checkout Service simultaneously, causing thundering herd on Redis and the database. With it, the Queue Gateway assigns each incoming request a queue position and holds the HTTP connection open (or returns a polling token), then allows checkout workers to drain the queue at a controlled rate - matching the throughput of the inventory and order systems.

Key Insight

The virtual waiting room is the architectural decision that makes everything else tractable. By serializing the burst at the edge, the downstream inventory and order systems see steady-state load rather than a tsunami. The queue absorbs the spike so that the atomic inventory counter and idempotent order creation can work correctly without being overwhelmed.

The Inventory Counter Design

The naive approach to inventory management is a database row with a count column and a WHERE count > 0 check before decrement. Under 500K concurrent requests, this creates a massive lock contention problem: every transaction races to read-modify-write the same row, throughput collapses, and the database primary becomes the bottleneck.

The correct approach is a cache-backed inventory counter using Redis’s DECR command, which is atomic at the single-key level. Redis processes commands in a single thread, so DECR inventory:product:42 is guaranteed to be serialized - no two decrements happen simultaneously, and the counter can never go below zero if we add a Lua check.

# Atomic inventory decrement with floor check using Redis Lua script
import redis

r = redis.Redis(host='redis-primary', port=6379, decode_responses=True)

# Load the atomic decrement Lua script
ATOMIC_DECR = r.register_script("""
local key = KEYS[1]
local current = tonumber(redis.call('GET', key))
if current == nil then
    return -2  -- key does not exist (sale not initialized)
end
if current <= 0 then
    return -1  -- sold out
end
return redis.call('DECR', key)
""")

def try_decrement_inventory(product_id: str) -> int:
    """
    Returns:
      >= 0: new inventory level after decrement (success)
      -1: sold out
      -2: sale not initialized
    Raises on Redis connection failure.
    """
    key = f"inventory:product:{product_id}"
    result = ATOMIC_DECR(keys=[key])
    return int(result)

# Initialize inventory at sale start
def initialize_inventory(product_id: str, count: int, ttl_seconds: int = 3600):
    key = f"inventory:product:{product_id}"
    r.set(key, count, ex=ttl_seconds, nx=True)  # nx=True: only set if not exists

The optimistic locking pattern applies when writing the final inventory state back to the database. Rather than using a database-level lock, we attach a version number to the inventory row and reject updates where the version has changed since we read it - detecting concurrent modifications without holding a lock.

# Optimistic locking for DB inventory sync
def sync_inventory_to_db(product_id: str, expected_version: int, new_count: int, db_conn):
    result = db_conn.execute("""
        UPDATE inventory
        SET quantity = %s,
            version = version + 1,
            updated_at = NOW()
        WHERE product_id = %s
          AND version = %s
    """, (new_count, product_id, expected_version))
    if result.rowcount == 0:
        raise OptimisticLockError(f"Inventory row for {product_id} was modified concurrently")
    return True

Premium Content

Unlock the full article along with everything else in the archive — all in one place.

In-depth analysis Expert insights Full archive access
Unlock Full Article