When only one unit remains, which order gets it?
TL;DR: For the last unit, do not authorize payment first—hold inventory for one order, show sold out to everyone else, then request payment authorization. Model available → reserved for checkout → sold (release on clear failure, expiry on abandonment, hold through uncertain payment outcomes). Use a single conditional UPDATE to claim stock, idempotent release, and treat payment timeouts as unknown until the processor confirms success or failure.
When only one unit remains, which order gets it?#
Two people place orders for a product that has one unit left, almost at the same time.
If both reach payment, both see “your order is being processed,” and only then does someone get a cancellation notice, the problem is not a wrong count of one. You trigger payment reversals, refunds, support load, seller firefighting, and lost trust.
The principle I want is simple:
Only one order should get the last unit. For everyone else, sold out beats a payment reversal.
So when stock is down to one, I do not authorize payment first. I hold inventory for a single order and show sold out to others. Then I request payment authorization. On a clear payment failure, release the hold immediately; on checkout abandonment, release only after a defined TTL.
The point is not the slogan “deduct inventory first.” Treat inventory as reserved and define who can confirm or roll back that reservation under each outcome.
Between deduction and payment authorization there is a reservation state#
If you decrement stock before taking payment, someone will ask:
When payment fails, how do you put inventory back?
The answer is reservation, not a permanent decrement. The moment you assign the last unit to an order, the state should be reserved for checkout, not sold. (These labels are illustrative—not tied to a particular product codebase.)
- Available for sale: New orders may claim a hold.
- Reserved for checkout: Only that order may complete payment; others see sold out.
- Sold: Payment is final; no further sales.
- Released: After failure, cancel, or expiry, stock returns to available for sale.
Stripe Checkout for limited inventory uses session expiry so unpaid customers do not hold stock indefinitely, and documents listening for expiry to restore reserved quantity.[1] Session TTL is not the universal answer for every payment method or custom checkout—set hold duration from time-to-pay, scarcity, and processor behavior.
UX follows the same rule: if the last unit is reserved for checkout, do not tease other customers with “almost yours.” Show sold out until the hold expires.
Claiming one order breaks if you split read and write#
The common mistake is read stock, decide in application code that stock exists, then decrement later.
1. Read current stock: 1
2. If stock > 0, create the order
3. Subtract 1 from stock Two requests can both read 1 at step 1 and both create orders. The bug is not the payment provider—it is separating the inventory decision from the write.
Claiming the last unit must put condition and mutation in one database write, for example:
UPDATE inventory
SET available_quantity = available_quantity - 1,
reserved_quantity = reserved_quantity + 1
WHERE product_id = :product_id
AND available_quantity > 0
RETURNING available_quantity; UPDATE changes only rows that satisfy WHERE; RETURNING gives back the new values.[3] No returned row means this request did not claim the last unit. Under PostgreSQL’s default Read Committed, concurrent updaters on the same row wait for the first commit, then re-evaluate WHERE against the updated row.[7]
That beats SELECT then “we still have stock, so decrement.” Multi-SKU carts, coupons, limits, and split carts may need transactions, lock ordering, or optimistic concurrency—not a single SQL generalization for every flow. Row locks block other writers until the transaction ends.[8]
Payment failure, abandonment, and timeout are not the same failure#
“Release the hold” does not mean one code path for every bad outcome.
1. Clear payment failure → recover immediately#
On an explicit decline (e.g. card rejected), release that order’s reservation at once. Release means only while that reservation is still active—not blindly adding 1 to stock.
Track reservation_id and states so RESERVED → RELEASED happens once. Duplicate failure webhooks or manual replays must not inflate inventory twice.
2. Checkout abandonment → recover via expiry#
Closing the browser does not always emit a failure event. Reservations need a server-side expiry time. After expiry, find the hold, release it, and refresh sold-out UI to available if appropriate.
Client countdowns are guidance only; the server owns expiry and state. Stripe describes restoring reserved inventory on checkout.session.expired after session expiry.[1]
3. Timeout → unknown outcome, not immediate failure#
The dangerous case: you sent a payment request and got no response. A network timeout does not mean the processor never received the request—it may have approved and the response is late. Treat external call timeouts as unknown until reconciled; without idempotency keys or inquiry APIs from the processor, you choose between duplicate charges and lost approvals.
Do not release inventory the instant a timeout fires. “Recover after the timeout window” here means:
- Define per-processor max response and webhook delay in operations policy.
- Until then, keep the reservation in an awaiting payment outcome state (payment may have succeeded, failed, or still be in flight—you do not know yet).
- After the deadline, query the processor and internal records; release only if there is no success.
- When a late success webhook collides with release, use conditional transitions so one wins.
That conservatism can briefly show false sold-out. It beats missing a successful charge or promising the last unit twice. If the processor offers no inquiry API or idempotency contract, this becomes a business risk choice, not purely engineering.
Retries must not create a new order#
Users double-tap pay; mobile networks drop responses. A repeated create-order request must return the first attempt’s outcome—not a new hold or new payment.
HTTP POST may apply state change on every call. An API can use a stable Idempotency-Key so the server treats replays as one operation—if it stores and enforces the key; support is API-specific.[4] Stripe returns the first result for the same key and errors when parameters differ under the same key.[5]
Do not treat the key as log decoration. Persist at least:
- Order or reservation ID for the key
- Processing state (
processing,completed,failed) - Fingerprint of the original input
- Stored response and retention period
In-flight duplicate keys should return “processing,” not a new order. Completed keys should replay the stored response. Idempotency is a contract to reuse the outcome of one business request, not magic in the header alone.
AWS recommends persisting idempotency tokens and related state and coordinating token records with state changes under proper concurrency control to avoid races.[6]
Do not try one DB transaction for inventory and the payment processor#
Inventory DB and an external processor cannot share one atomic transaction. You need explicit success and compensation per step, not “finish both at once.”
Available for sale
→ Create reservation / claim stock
→ Request payment
→ Payment success: confirm reservation / order complete
→ Explicit payment failure: release reservation
→ Customer leaves: release after expiry
→ Response timeout: payment pending → query/deadline → confirm or release That sequence of local changes and compensating steps is a saga. AWS distinguishes compensating transactions for business-rule failures (roll back inventory/order state) from forward recovery (retries) for infrastructure failures.[2]
If order confirmation triggers notifications, fulfillment, or analytics events, you can still die after committing inventory but before publish. Transactional outbox (same transaction for state + outbox row, relay publishes later) is worth considering at scale—but for a small order flow, get reservation / confirm / release transitions right first.
Counterpoint: Won’t holding stock before payment hurt conversion?#
Yes, sometimes. Long holds let non-payers block scarce SKUs while others only see sold out. Short holds on scarce items can feel pressuring.
This post is not “every shop needs long holds.”
- Plenty of stock: deduct after payment may stay simpler.
- Last units, limited drops, seats/tickets: reservation often wins when oversell cost is high.
- Hold duration should follow processor limits, real pay times, and the cost of cancellations—not marketing copy alone.
- Release jobs must be idempotent so one reservation is not freed twice.
My rule:
If sold out beats a payment reversal for your customers, manage the last unit by reservation state—not by payment outcome alone.
The last unit is not “whoever’s HTTP request arrived first.” It belongs to the order that claims stock once and keeps that promise until payment outcome is clear.