A store launches a flash sale: 10 pairs of limited sneakers. At 8:00 p.m. sharp, a hundred people tap Buy.

The next morning, the warehouse finds 100 paid orders for 10 pairs of shoes. Worse, the admin panel still says 5 pairs in stock. The team now has 90 refunds to issue, 90 angry customers, and a stock number nobody trusts.

(The scenario is illustrative. The numbers are not: they come from the experiment below, which you can run yourself.)

This article shows why "check the stock, then save" fails under concurrency, and compares three fixes: a conditional UPDATE, a row lock, and stock reservations for checkouts with a payment step. Each one is measured with the same test: 10 units in stock and 100 buyers at the same moment.

The bug: read, check, write#

This is the code most stores start with. It reads naturally and passes every manual test:

The naive versionPHP
$product = Product::findOrFail($request->integer('product_id'));

if ($product->stock < $qty) {
    return response()->json(['error' => 'Out of stock'], 409);
}

// ... validate the cart, apply the coupon, calculate shipping (≈ 50 ms)

$product->stock -= $qty;
$product->save();

Order::create([...]);

The problem is the gap between reading the stock and writing it back. When 100 requests arrive together, they all read stock = 10, they all pass the if, and they all create an order.

Then a second bug appears. Each request saves the number it calculated (10 - 1 = 9, or 8 if it read a little later), so most decrements overwrite each other. That's called a lost update. That's why the stock ends at 5 after 100 sales: the counter is no longer counting anything.

The experiment#

Three versions of POST /buy, each run 5 times with a fresh product of 10 units and 100 parallel requests for 1 unit each:

Results table: read-check-save sold 100 units and left the stock at 4–5; the conditional UPDATE and the row lock both sold exactly 10 and left stock at 0; the row lock took about twice as long
10 units in stock, 100 buyers at the same moment
Approach Units sold Stock after 409 "out of stock" Time for 100 requests
Read, check in PHP, save 100 (every run) 4–5 (wrong) 0 0.36–0.44 s
Conditional UPDATE 10 0 90 0.33–0.39 s
Row lock (SELECT … FOR UPDATE) 10 0 90 0.67–0.72 s

Setup: Laravel 13 on PHP 8.4, PHP-FPM with 24 workers behind nginx, MySQL 8.0.28, Apple M1 Pro. Each request does 50 ms of "work" (validation, pricing) between the check and the write, because real checkouts do. The requests were fired with 100 parallel curl processes, since tools that send requests one by one hide this bug completely.

Both fixes are correct. The difference is cost: the row lock makes buyers wait in line while each one holds the lock, which doubled the total time here. With slower work inside the lock, the queue gets longer.

Fix 1: let the database check and decrement in one step#

Conditional UPDATEPHP
$sold = DB::table('products')
    ->where('id', $productId)
    ->where('stock', '>=', $qty)
    ->decrement('stock', $qty);

if ($sold === 0) {
    return response()->json(['error' => 'Out of stock'], 409);
}

Order::create([...]);

This runs a single statement:

SQL
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock >= 1;

MySQL locks the row for the length of that one statement. The check (stock >= 1) and the write (stock - 1) happen together, and the result tells you whether it worked: 1 row affected means you got the unit, 0 means somebody else got the last one. There's no gap left for a race.

Two things make it robust:

  • Put the slow work before the UPDATE, not between a read and a write. Validation and pricing don't need the row.
  • Make the column UNSIGNED as a last line of defence. If some other code path ever tries to push stock below zero, MySQL 8 refuses with an out-of-range error instead of storing -3.

Fix 2: lock the row when the decision needs more than one field#

Sometimes the rule is more complicated than stock >= qty, like "max 2 per customer", "pre-orders allowed up to 50 below zero", or bundles that need several products. Then you read, decide in PHP, and write, all while holding a lock:

Row lockPHP
return DB::transaction(function () use ($productId, $qty) {
    $product = Product::lockForUpdate()->findOrFail($productId);

    if ($product->stock < $qty) {
        return response()->json(['error' => 'Out of stock'], 409);
    }

    // Keep this part SHORT: every other buyer of this product is waiting.

    $product->decrement('stock', $qty);

    return Order::create([...]);
});

lockForUpdate() adds FOR UPDATE to the SELECT. The second request for the same product waits at that line until the first transaction commits, then reads the new stock.

Rules for using locks safely:

  • Lock as late and as briefly as possible. Never call a payment gateway or send an email while holding the lock.
  • Lock rows in a consistent order (for example, by product ID) when one order contains several products. Otherwise two carts locking A→B and B→A can deadlock.
  • Expect deadlocks anyway and retry them. Laravel's DB::transaction($callback, attempts: 3) retries the whole transaction for you.

Fix 3: reservations, when payment happens between "buy" and "sold"#

Real checkouts have a payment step that can take minutes: a 3-D Secure screen, a wallet app, a bank redirect. You can't hold a row lock that long, and you shouldn't sell the unit to someone else meanwhile. The usual answer is a reservation with an expiry:

  1. When checkout starts, atomically take the stock (Fix 1) and record a reservation that expires in, say, 15 minutes.
  2. Payment succeeds → mark the reservation converted. The stock stays sold.
  3. Payment fails or the customer leaves → release it and give the stock back.
  4. A scheduled job releases reservations whose time is up.
app/Inventory.phpPHP
class Inventory
{
    /** Hold stock for a checkout. Returns the reservation id, or null when there isn't enough. */
    public function reserve(int $productId, int $qty, int $minutes = 15): ?int
    {
        return DB::transaction(function () use ($productId, $qty, $minutes) {
            $taken = DB::table('products')
                ->where('id', $productId)
                ->where('stock', '>=', $qty)
                ->decrement('stock', $qty);

            if ($taken === 0) {
                return null;
            }

            return DB::table('stock_reservations')->insertGetId([
                'product_id' => $productId,
                'quantity' => $qty,
                'status' => 'held',
                'expires_at' => now()->addMinutes($minutes),
                'created_at' => now(),
                'updated_at' => now(),
            ]);
        });
    }

    /** Payment succeeded: the held stock is sold. False if the hold already expired. */
    public function convert(int $reservationId): bool
    {
        return DB::table('stock_reservations')
            ->where('id', $reservationId)
            ->where('status', 'held')
            ->update(['status' => 'converted', 'updated_at' => now()]) === 1;
    }

    /** Payment failed or was abandoned: give the stock back, exactly once. */
    public function release(int $reservationId): bool
    {
        return DB::transaction(function () use ($reservationId) {
            $reservation = DB::table('stock_reservations')->where('id', $reservationId)->lockForUpdate()->first();

            if (! $reservation || $reservation->status !== 'held') {
                return false; // already converted or released
            }

            DB::table('stock_reservations')->where('id', $reservationId)->update(['status' => 'released', 'updated_at' => now()]);
            DB::table('products')->where('id', $reservation->product_id)->increment('stock', $reservation->quantity);

            return true;
        });
    }

    /** Scheduled every minute: release holds whose time is up. */
    public function releaseExpired(): int
    {
        return DB::table('stock_reservations')
            ->where('status', 'held')
            ->where('expires_at', '<=', now())
            ->pluck('id')
            ->sum(fn (int $id) => (int) $this->release($id));
    }
}
routes/console.phpPHP
Schedule::call(fn () => app(Inventory::class)->releaseExpired())->everyMinute();

The important detail is status checks inside conditional updates. convert() only works on a held reservation, and release() locks the reservation and only releases a held one. I tested the edge cases against MySQL:

  • Reserving 2 + 2 from a stock of 3: the second reservation is refused.
  • Releasing a reservation that was already converted is refused, and the stock doesn't change.
  • An expired hold is released once. Running the job again releases nothing, and converting the expired hold afterwards is refused.

That last case matters: if a payment confirmation arrives after the reservation expired, convert() returns false. Then you must decide whether to re-reserve (if there's still stock) or refund. Don't silently create an order for stock you already gave back.

Which one should you use?#

Situation Use Why
Simple "is there enough stock?" Conditional UPDATE Fastest, no gap, no lock held across your code
Rules that need several fields or rows (per-customer limits, bundles) Row lock in a short transaction You can read and decide in PHP safely; the cost is queueing
A payment step between checkout and confirmation Reservation with expiry (built on the conditional UPDATE) Stock is held for the buyer without holding a database lock
Very hot single product (thousands of buyers per second) A Redis counter (DECRBY) or a queue in front, with the database as the final record One MySQL row becomes the bottleneck; measure before you add this
Stock shared across warehouses or sales channels Reservations per warehouse, plus regular reconciliation One number can't describe stock in several places

Optimistic locking (a version column, UPDATE … WHERE version = ?) is another correct option. It works well when conflicts are rare, but in a flash sale almost every request conflicts and has to retry, so the conditional UPDATE is simpler for stock.

Common mistakes#

Mistake What happens Fix
find() → if → save() Oversells, and the stock number drifts (lost updates) Conditional UPDATE, or a row lock
lockForUpdate() without a transaction The lock is released immediately, with no protection Always inside DB::transaction()
Calling the payment gateway inside the lock Every buyer of that product waits for the slowest card Reserve first, pay outside the lock
Reservations that never expire Abandoned carts take stock forever expires_at and a scheduled release
Releasing without checking the status A late job gives back stock that was already sold Release only held reservations, under a lock
Testing with one browser tab The race never appears 100 parallel requests on a multi-process server
Showing "In stock" from a cached number Buyers see stock that's already gone Treat the displayed number as a hint; the UPDATE decides

Checklist#

  • No code path reads stock in PHP and then writes a calculated value back
  • Every decrement has WHERE stock >= :qty and checks the affected rows
  • The stock column is UNSIGNED
  • Row locks are only used inside short transactions, with no network calls
  • Multi-product orders lock rows in a consistent order and retry deadlocks
  • Checkouts with a payment step use reservations with an expiry
  • Expired reservations are released by a scheduled job, exactly once
  • Late payment confirmations for expired reservations are handled (re-reserve or refund)
  • The checkout endpoint is idempotent, so retries don't reserve twice
  • Concurrency is tested with real parallel requests

Key takeaways#

  • "Check, then write" oversells. In the test, 100 buyers bought 10 units 100 times, and the stock counter ended at 5 because updates overwrote each other.
  • A conditional UPDATE fixes it in one line, with no extra cost: exactly 10 sold in every run.
  • Row locks are also correct but make buyers queue. They were twice as slow here, so keep them short and only use them when the rule needs it.
  • Payments need reservations: take the stock atomically, hold it with an expiry, and convert or release it exactly once.

References#


Running flash sales or a busy store? Overselling, stuck reservations and stock that doesn't match the warehouse usually come from the same few patterns. If you'd like your checkout reviewed before the next big sale, see how I can help.