System designDeep dive12 min

Inventory & booking contention

When thousands of people try to buy the last few units at the same moment, the hard part isn't the traffic but the count: it must never go below zero, it lives on one hot row, and items sit in carts that may never pay. Contention is solved with atomic conditional updates, split counters, holds that expire, and a queue in front, and sometimes by overselling on purpose with a remedy ready.

Watch it evolve1 / 10
The problem

A sneaker release at 10:00

Size 42 has 600 pairs, and tens of thousands of people press Buy within the first minute. Every one of them aims at the same count.

80
Start
Stage 1
Stage 2
Stage 3
Stage 4
Final

1 · The problem

Read, check, then write sells the last pair twice

A sportswear brand releases a limited sneaker at 10:00. Size 42 has 600 pairs, and tens of thousands of people press Buy within the first minute. The checkout code does what looks obviously right: read the stock, check that it is above zero, then write the new value. With one pair left, two requests arrive together:

Both checkouts saw one pair and both wrote zero. The stock looks right afterwards, which is what makes the bug hard to see: the evidence is in the orders, not the count.

This is a lost update, one of the anomalies weak isolation allows (Transactions & isolation levels). At low traffic it almost never happens, because two requests rarely land in the same few milliseconds. At a release, it's the normal case: every buyer is aiming at the same row at the same moment.

Four problems follow from that one row, and this page takes them in turn: making the decrement itself correct, getting enough throughput out of a single hot item, holding stock for people who haven't paid yet, and keeping the crowd from overwhelming the system. Some businesses then add a fifth, on purpose: selling more than they have.

2 · Atomic

Make the check and the decrement one statement

The fix is to let the database do the check and the write together, so no other request can act between them. In SQL that is a conditional update:

Take one pair, only if one is left

sql
UPDATE stock
   SET qty = qty - 1
 WHERE sku = 'RUN-42'
   AND qty >= 1;
-- 1 row updated: the pair is yours
-- 0 rows updated: sold out

Why this is safe even at PostgreSQL's default Read Committed level: if two such updates target the same row, the second waits for the first to commit, then re-evaluates its WHERE clause against the updated row. With one pair left, the first sets qty to 0, the second re-checks qty >= 1, finds it false, and updates nothing. The application reads the affected-row count and tells the second buyer the size is sold out.

Technique

How it works

When to use it

Conditional update

Check and decrement in one statement

A count of identical units

Lock the row first

SELECT ... FOR UPDATE, then check and write

Logic too complex for one statement

Version check

Write only if the version is still the one read; retry otherwise

Rare conflicts; no locks held

Conditional write in a key-value store

DynamoDB's condition expressions; fails with ConditionalCheckFailedException

The stock lives in DynamoDB

Constraint as a backstop

CHECK (qty >= 0) on the column

Always, to catch a bug in any of the above

One trap: a plain increment or decrement is not idempotent. It holds for any atomic counter: if the request is retried after a timeout, the stock is decremented twice. The purchase needs an order id that the decrement is tied to, so a retried request finds the existing order instead of taking a second pair (Idempotency).

3 · Hot row

One item is one row, and one row can only go so fast

Correctness has a price. Every purchase of size 42 now waits its turn on the same row, holding the row lock from its UPDATE until its transaction commits. If that takes 4 ms, including the commit's write to disk and the application's round trips, the row allows at most 1 ÷ 0.004 = 250 purchases a second, however many servers sit in front of it. That is a hot row, the single-key case of Hot partitions & skew.

Two moves raise the ceiling. First, keep the transaction that holds the lock short: decrement and commit, with no payment call or other slow work inside it. Second, split the count, as hot counters are split: 600 pairs become 10 rows of 60, each request picks a row at random, and each row has its own lock:

1 row
250 purchases/s
4 rows of 150
1,000 purchases/s
10 rows of 60
2,500 purchases/s

Ceiling = rows ÷ 4 ms lock time, assuming requests spread evenly and the database itself keeps up. Splitting multiplies the ceiling; it doesn't remove the need for each row's decrement to be atomic.

Splitting has an edge case at the end of a sale: one row may be empty while another still has pairs. A request that finds its row empty tries the others before saying "sold out", and the true total is always the sum of the rows. Near the end, few rows remain and contention returns, which is acceptable because few pairs remain too.

The other common choice is an in-memory counter in front of the database. Redis runs each command, and each script, atomically: while a script executes, no other command runs. A script that checks the count and decrements it can admit buyers at memory speed, and only admitted buyers go on to write a durable order. The counter is then a gate, not the record: if it's lost, it's rebuilt from the orders table, which stays the source of truth (In-memory data-structure stores).

4 · Holds

A cart is a hold with an expiry, or stock disappears into abandoned checkouts

Taking a pair the moment someone presses Buy is fair, but payment takes minutes, and many buyers never finish. If the pair stays taken, abandoned carts make the size look sold out while pairs sit unsold. If it's only taken at payment, a buyer can fill in card details and then be told it's gone. The usual answer is a hold: the pair is reserved for a fixed time, then released automatically if the purchase isn't completed.

Availablecounted in stockHeldexpires in 10 minSoldpaidBuy pressedpayment succeedsexpires or canceledAvailablecounted in stockHeldexpires in 10 minSoldpaidBuy pressedpayment succeedsexpires or canceled

Available = total − sold − active holds. An expired hold counts as available at once: the next purchase returns it to stock in the same transaction that takes a pair, before any cleanup job runs.

The design details:

  • Expiry is a timestamp, checked whenever availability is computed. A background job deletes expired holds later, but correctness never waits for it.
  • Taking a hold is the atomic step. Under the item's row lock, one transaction first returns that item's expired holds to stock, then takes a pair with the conditional update above. Payment happens outside that transaction.
  • One hold per buyer and item, so a bot can't hold the whole size by opening many carts.
  • The hold and the payment must agree on time. Stripe Checkout, for example, lets a session expire between 30 minutes and 24 hours after creation, 24 hours by default. A 10-minute hold with a 30-minute payment session means a payment can arrive after the hold expired.

So the payment handler must expect that case. When payment succeeds for an expired hold, it tries to take a pair again with the same conditional update; if one is free, the order completes, and if not, the payment is refunded at once and the buyer told. Stripe sends an event when a session expires, which is the signal to release a hold early. The whole sequence of hold, pay, then confirm or compensate is a small saga (Distributed transactions: 2PC & sagas).

Stock of distinct units, such as seats, rooms or appointment slots, works the same way, one row per unit. When any unit will do, such as any room of a given type, many buyers can each grab a different free row with PostgreSQL's FOR UPDATE SKIP LOCKED, which skips rows other transactions have locked. Skipping gives an inconsistent view of the data, fine for picking any free unit but not for counting them.

5 · The crowd

Put a queue in front, so the crowd waits outside instead of inside

Even with a correct, split counter, letting every buyer in at once means product pages, carts and payment all receive the surge, and the error pages land on the people who were first. A virtual waiting room limits how many people are inside the purchase flow at once. Cloudflare's version shows the shape:

  • It admits visitors directly until traffic approaches limits the site owner sets, as total active users and new users per minute.
  • Past that, visitors wait in a queue and are let in first in, first out, tracked by a cookie.
  • Waiting browsers refresh every 20 seconds with an estimated wait time.
Buyersall arrive at the dropWaiting roomfirst in, first outPurchase flowactive users cappedStock counters4 ms decrementqueue positionadmitted at a set ratehold a pair

The crowd waits in the room, not in the checkout. Inside, the purchase flow only ever sees as many buyers as it was sized for.

For the release, the waiting room does three jobs. It protects the checkout path, so the 4 ms decrement isn't hidden behind overloaded web servers (Load shedding & backpressure). It makes order fair and visible, so buyers see a position instead of errors. And it gives the brand one place to apply rules before stock is touched: one hold per account, limits per payment card and address, and bot checks.

A waiting room also lets the sale end gracefully. When the counters reach zero, the room can stop admitting and tell everyone still queued that the size is sold out, rather than letting them through to find out on the product page. If holds expire and pairs come back, it admits that many more people.

6 · Overselling

Some businesses oversell on purpose, and plan the remedy in advance

Not every business treats zero as a hard floor. Airlines sell more seats than a flight has, because some passengers don't show up. US regulations don't forbid this; they define what happens when a flight is actually oversold:

US rule for oversold flights

What it requires

Volunteers first

Airlines must ask for volunteers before denying anyone boarding against their will

Arrival within 1 hour of plan

No compensation required

Domestic, 1 to 2 hours late

At least 200% of the fare to the destination, up to $1,075

Domestic, more than 2 hours late

At least 400% of the fare to the destination, up to $2,150

The lesson for system design is that overselling is a business decision, not a bug, only when the system knows the limit and the remedy. An oversell allowance is a number per flight or per product, enforced by the same atomic check (qty >= -allowance), and the remedy, compensation, a substitute or a refund, is a process the system can run. A sneaker release with no backup stock and no remedy shouldn't oversell at all.

7 · The checklist

Correct first, then fast, then fair

For the sneaker release, and any sale where demand exceeds supply at one moment:

Decision

Release choice

The decrement

Conditional update tied to an order id; CHECK (qty >= 0) as a backstop

Throughput

Short transactions; size 42 split into 10 rows; an in-memory gate rebuilt from orders

Holds

10 minutes, expiry checked on read, one per buyer and item

Payment

Outside the lock; late payments re-take a pair or are refunded

The crowd

A waiting room with first-in-first-out admission and per-account limits

Overselling

None: no backup stock and no remedy

Check yourself

4 questions

1. With one pair left, two checkouts run UPDATE stock SET qty = qty - 1 WHERE sku = 'RUN-42' AND qty >= 1 at the same time under Read Committed. What happens?
2. Each purchase holds the stock row's lock for about 4 ms. What is the most purchases per second for that item, and what raises it?
3. Holds last 10 minutes, but the payment provider's checkout session lasts at least 30. A payment succeeds 15 minutes after the hold. What should happen?
4. When is selling more units than you have a reasonable design?

Takeaways

Remember this

  • Read-check-write oversells under concurrency; make the check and the decrement one atomic conditional update, with a constraint as a backstop.
  • Tie every decrement to an order id, because increments and decrements aren't idempotent on retry.
  • One item's stock is one hot row: keep locked work short and split the count across several rows to raise the ceiling.
  • Hold stock for a limited time with expiry checked on read, and make the payment path handle a hold that expired first.
  • Put a waiting room in front of a surge, and oversell only with a defined limit and remedy.