Design Inventory for a Flash Sale

Updated · By

Keep the sale's stock in an in-memory counter that only decrements atomically while stock remains, so it can never go below zero. Put a waiting room in front so only a manageable rate of buyers reaches it, give each successful buyer a short reservation, and return stock from reservations that expire unpaid. Isolate all of it from normal shopping.

hardPattern: Sharding and replicationFrom Devana's Amazon question set

Problem

A limited quantity of a popular item goes on sale at a fixed time, and millions of customers press buy within the same few seconds.

Design it so that you never oversell, never leave stock unsold because of abandoned checkouts or errors, and keep the rest of the site working normally.

POST /sales/{sale_id}/reserve    {customer_id, request_id}  -> {reservation_id, expires_at} | sold_out | queued
POST /reservations/{id}/confirm  {payment}                  -> {order_id}

This is one of Devana's own questions, written in the style of Amazon's interviews. It is not a question leaked from Amazon, and Devana is not affiliated with Amazon.

Requirements

  • 10,000 units available.
  • 2 million buy attempts within 5 seconds.
  • Overselling by even one unit is unacceptable.
  • The rest of the site must not degrade.
  • Worth asking first: how long may a buyer hold a unit before paying? Ten minutes here.

Scenarios

Scenario 1

2 million buy presses in 5 seconds for 10,000 units. Expected: 10,000 reservations, and everyone else told quickly that it is sold out or that they are in a queue.

The counter answers in microseconds, so the hard part is not the decrement but keeping 400,000 requests a second away from everything else.

Scenario 2

A buyer reserves a unit and closes the tab. Expected: The unit goes back on sale ten minutes later.

The reservation expires, a sweeper adds the unit back to the counter, and the next buyer can take it.

Hints

Open one at a time, and try the problem again after each.

Hint 1

What goes wrong with reading the stock and then writing stock minus one, under concurrency? And with a row lock?

Hint 2

How many requests a second arrive at the peak, and how many of them can possibly succeed?

Hint 3

Which operation guarantees the count never goes below zero without holding a lock across a network round trip?

Hint 4

What happens to a unit when a buyer reserves it and never pays?

A worked answer

Requirements and estimates

2 million attempts in 5 seconds is about 400,000 requests a second, all for one item, against 10,000 possible successes. 99.5% of requests will fail, so failing them cheaply and fairly is most of the design.

Why a database row with a lock fails

Every request contends for the same row. A read followed by a write, without a lock, oversells. With a lock, requests queue on one row, throughput is capped far below the demand, waiting requests hold connections, and the database that other pages depend on slows down too.

Admission control in front

Serve the product page from a CDN before the sale. At the start, a gate at the edge lets through a bounded rate, sized to what the reservation service can handle, and puts everyone else in a waiting room with a position. Once the stock reaches zero, the edge answers "sold out" itself without touching the backend.

Atomic reservations

Hold the stock in an in-memory store and decrement it with one atomic operation that checks and decrements together, such as a Lua script in Redis or a conditional update. A success creates a reservation with an expiry, and the request id makes retries idempotent.

To spread the load, split the 10,000 units across several counters and route each request to one of them; a request that finds its counter empty tries another before reporting sold out.

Confirming orders and returning stock

Payment and order creation happen behind a queue, at a pace the order system can take. A confirmed reservation becomes an order in the durable database. A sweeper returns expired reservations to the counter, and if the in-memory store fails, the counter is rebuilt from the database as units minus orders minus live reservations.

How to say it in the interview

Put the 400,000 requests a second next to the 10,000 possible successes early. It reframes the problem: the decrement is easy, and rejecting 99.5% of requests cheaply and fairly is the design. Then show why the row lock fails, describe the atomic check-and-decrement, and finish with reservations and expiry, which is where naive designs lose stock.

The core in code

The reservation logic without the network: an atomic check-and-decrement, holds that expire, and idempotent retries. In production these steps run inside one atomic operation in the in-memory store; here a single-threaded class stands in for that atomicity.

Python

class FlashSaleInventory:
    def __init__(self, units, hold_seconds=600):
        self.available = units
        self.hold = hold_seconds
        self.holds = {}          # request id -> expiry time of its reservation
        self.sold = set()        # request ids whose reservation became an order

    def reserve(self, request_id, now):
        self.expire(now)
        if request_id in self.holds or request_id in self.sold:
            return True          # a retry of a request that already succeeded
        if self.available == 0:
            return False         # sold out: the count never goes below zero
        self.available -= 1
        self.holds[request_id] = now + self.hold
        return True

    def confirm(self, request_id, now):
        self.expire(now)
        if request_id not in self.holds:
            return request_id in self.sold     # expired, or never reserved
        del self.holds[request_id]
        self.sold.add(request_id)
        return True

    def expire(self, now):
        for request_id in [r for r, until in self.holds.items() if until <= now]:
            del self.holds[request_id]
            self.available += 1                # back on sale

JavaScript

class FlashSaleInventory {
  constructor(units, holdSeconds = 600) {
    this.available = units;
    this.hold = holdSeconds;
    this.holds = new Map(); // request id -> expiry time of its reservation
    this.sold = new Set();  // request ids whose reservation became an order
  }

  reserve(requestId, now) {
    this.expire(now);
    if (this.holds.has(requestId) || this.sold.has(requestId)) return true; // a retry
    if (this.available === 0) return false; // sold out: the count never goes below zero
    this.available -= 1;
    this.holds.set(requestId, now + this.hold);
    return true;
  }

  confirm(requestId, now) {
    this.expire(now);
    if (!this.holds.has(requestId)) return this.sold.has(requestId); // expired, or never reserved
    this.holds.delete(requestId);
    this.sold.add(requestId);
    return true;
  }

  expire(now) {
    for (const [requestId, until] of [...this.holds]) {
      if (until <= now) {
        this.holds.delete(requestId);
        this.available += 1; // back on sale
      }
    }
  }
}

Both versions are tested against the same cases, including every example on this page.

  • expire scans every hold, which is fine for a sketch. In production, keep holds ordered by expiry, such as a sorted set in Redis, and pop from the front.
  • A retried request returns success without taking a second unit; the request id is the idempotency key.
  • available changes in only two places, a guarded decrement and an expiry, which is what makes "never below zero" easy to argue.

Follow-up questions

Why does a single counter with a database lock fail here?

It queues 400,000 requests a second on one row. Throughput is capped far below the demand, waiting requests hold connections, and the shared database slows every other page.

How do you handle customers who reserve then abandon?

Short holds that expire and return the unit to the counter. The length is a product decision: shorter returns stock sooner, longer gives slower payers a fair chance.

How do you keep this traffic from starving normal shopping?

Separate everything the sale touches: its own edge rules, its own reservation service and in-memory store, and a queue in front of the shared order system, so normal pages never wait behind sale traffic.

How do you make the queue feel fair to customers?

Admit in order of arrival at the edge from a fixed start time, show each customer their position, and limit each account to one reservation. Randomizing order within the first second avoids rewarding whoever has the lowest latency.

Common mistakes

  • Reading the stock and writing stock minus one as two separate operations.
  • Letting every request reach the database.
  • No expiry on reservations, so abandoned checkouts strand stock.
  • No idempotency, so a retried request takes a second unit.

Practice it in a mock interview

Reading a solution and explaining one under questioning are different skills. The button below starts this problem as a voice mock interview with Josh, Devana's AI interviewer, with a whiteboard he can read.

Practice this problem in a mock interview

Practice this problem out loud

Josh, Devana's AI interviewer, runs this exact problem as a voice mock interview, asks follow-ups like the ones above, and scores you out of 100. The Free plan has 3 interviews a month, no card needed.

Practice this problem in a mock interview