Back to blog
Article

Two orders for the last unit in the same second

Two orders for the last unit in the same second
S

StriveBit

3 min readE-commerce

Two orders for the last unit in the same second

A D2C client runs a Diwali drop: 300 units of a ₹4,200 brass lamp, each serialised, no restock promise. At 11:00:01 IST their server logs show two `POST /api/checkout` requests in the same second. Both read `stock = 1` from Postgres. Both pass the `stock > 0` guard. Both decrement. Both charge the card via Razorpay. One lamp, two paying customers.

The fix depends on where your inventory truth lives. For most Shopify-fronted stores we build for Indian D2C clients, the answer is: don't trust the application layer with stock at all.

The naive guard and why it fails

The checkout handler usually looks like this:

const product = await db.query('SELECT stock FROM products WHERE id = $1', [id]);
if (product.stock <= 0) return res.status(409).json({ error: 'out_of_stock' });
await db.query('UPDATE products SET stock = stock - 1 WHERE id = $1', [id]);
await chargeCard();
await createOrder();

Under Read Committed (Postgres default), both transactions see `stock = 1`. The `SELECT` is a snapshot. By the time the `UPDATE` runs, the other transaction has already decremented, but your `UPDATE` doesn't re-check the row — it just subtracts. You get `stock = -1` and two orders.

Adding `FOR UPDATE` on the `SELECT` serialises the two transactions, so the second one blocks until the first commits. That works, but it turns your checkout into a queue. Under a 2000-req/sec drop, you're holding row locks for the duration of a Razorpay call — 800ms to 3 seconds. Connection pool exhausts, request timeouts cascade, and the lamp sells out to nobody.

What we actually do

We move the stock check into a single atomic `UPDATE` and only proceed if it affected a row:

const result = await db.query(
  'UPDATE products SET stock = stock - 1 WHERE id = $1 AND stock > 0 RETURNING stock',
  [id]
);
if (result.rowCount === 0) {
  return res.status(409).json({ error: 'out_of_stock' });
}

The row-level lock is held for milliseconds, not for the duration of a payment gateway round-trip. If `rowCount` is zero, the unit is gone — we refund nothing because we haven't charged yet. The charge happens after the reservation succeeds.

The tradeoff: if the Razorpay call fails after reservation, you hold a unit for an abandoned checkout. We handle this with a 15-minute `reserved_until` timestamp instead of a straight decrement. A cron job releases expired reservations back to sellable stock. The customer who lost the race sees a real `409` instead of a spinner.

For the clients who need it — flash sales, limited drops, festival traffic — this is the difference between overselling and not. For a steady-state store doing 50 orders a day, the naive guard is fine. We don't add complexity where the failure mode shows up once a year.

The Diwali drop sold out in 47 seconds. Zero oversells, zero double-charges, one refunded abandoned cart where a card failed after reservation.

Back to all articles

Ready to build something great?

We help ambitious teams build software that lasts. If you're interested in working with us or want to discuss your project, let's connect.

Get in touch