Holds, expiry, payment coupling and never double-booking
coreadvancedA reservation system exists to enforce one invariant — a unit of inventory is sold at most once — under conditions designed to break it: thousands of people wanting the same seat in the same second, a payment step that takes seconds or minutes and can fail, and clients that retry. The mechanism is a temporary hold. Selecting a seat does not sell it; it creates a hold row with an expiry, and that row is what makes the seat unavailable to everyone else while one buyer completes payment. The hold is enforced by a unique constraint on the unit, so two concurrent attempts cannot both create one — the loser is told the seat has gone, immediately, rather than after they have entered their card details. Expiry is what makes abandonment safe: a buyer who closes the tab does not remove their own hold, so the hold must release itself, which means a stored `expires_at` compared at read time rather than a cleanup job that decides when a seat is free. Payment is coupled to the hold rather than the other way round: the hold is created first, payment runs against it, and success converts the hold into a booking inside a transaction that re-checks the hold is still valid — because a payment that takes four minutes against a three-minute hold has to fail loudly, and refund, rather than quietly overbook. Idempotency covers the retries: a client that resubmits a purchase must get the original booking back, not a second one, which is the same idempotency-key mechanism a payment flow uses. And the whole design refuses to rely on any application-level check, because the invariant here is the product.
Think of it as
A cloakroom peg with a slip on it that says "reserved until 14:32". Anyone else who reaches for the peg sees the slip and moves on. If the person who put it there does not come back, nobody has to notice or tidy up — at 14:32 the slip stops meaning anything and the peg is free. The one rule that matters is that only one slip can ever be on a peg, and that rule is enforced by the peg having room for exactly one, not by everyone being careful.
What we're doing: Run one seat through a high-contention sale and watch each mechanism do its job.
- 5
- The unique constraint turns a contention problem into a fast, clean answer for 3,399 people. Any design that instead queues them on a lock makes all 3,399 wait to be told the same thing.
- 16
- The idempotency key is scoped to the hold rather than to the attempt, so a resubmission is recognised as the same purchase. This is the same mechanism the payment section describes, applied to the operation that couples payment to inventory.
- 21
- Re-checking the hold inside the converting transaction, with a row lock, is what closes the gap that payment duration opens. Checking before the payment call proves nothing about the state after it.
- 31
- This branch is the reason the hold duration is a product decision. It must be long enough that a normal payment finishes comfortably inside it, because the alternative to refusing here is selling one seat twice.
Why this works: Each mechanism handles a failure the others cannot: the constraint handles concurrency, the expiry handles abandonment, the idempotency key handles retries, and the re-check inside the converting transaction handles the gap that payment latency opens. Removing any one of them produces a specific, reproducible way to sell the same seat twice.
Checking availability, then booking
Wrong
Better
What you see: Double bookings that cluster at the moment a popular sale opens and are impossible to reproduce afterwards, because they need two requests to fall inside the same window — a window the payment step stretches from milliseconds to minutes.
Why: The availability check describes a state that stops being true the instant it is read, and the payment call in the middle widens the gap by orders of magnitude. Taking an atomic hold first collapses the check and the claim into one operation, and re-checking at conversion covers the remaining time.
- Buyer A → Booking service: select seat 14C
- Booking service → Database: INSERT hold (expires in 8m)
- Database → Booking service: hold created
- Buyer B → Booking service: select seat 14C
- Booking service → Database: INSERT hold → unique violation (B is told immediately, before entering card details)
- Booking service → Payment provider: charge A (idempotency key)
- Payment provider → Booking service: succeeded, 9 minutes later
- Booking service → Database: convert hold → booking, re-check hold
- Database → Booking service: hold expired — refuse, refund A
The four states a seat moves through
Six requirements, and the mechanism for each
Remember: The invariant is "sold at most once", and it is enforced by a unique constraint on the held unit rather than by any availability check. Selection takes an atomic hold with an `expires_at`, so a lost race is answered immediately and an abandoned checkout releases itself without anyone noticing. Compare the expiry at read time so a sweep is an optimisation, not a correctness dependency. Carry an idempotency key so a retried purchase returns the original booking. And convert the hold inside a transaction that re-checks it, because a payment that outlives its hold must fail and refund rather than overbook.
See also: choosing enforcement mechanisms · optimistic vs pessimistic · concurrency control mechanisms · payment correctness building blocks · idempotency keys for post requests · prefer simpler mechanisms

