Ticket Booking & Inventory Reservation
Two people click the same seat in the same millisecond. One must lose, and neither may be charged for a seat they do not get. The same problem sits under concert tickets, hotel rooms, flights, and limited-stock drops, and the interview is almost never about the happy path. It is about the second click, the abandoned cart, the late payment, and the million people arriving at ten o'clock.
Prerequisites: Database transactions, idempotency keys, and the saga pattern.
Cover these firstThe Design Round ItselfIdempotency & Exactly-Once Effects in PaymentsSaga, Outbox & CDC for Payments
After this: Prevent double-selling with atomic holds, model date ranges and counted pools, and design the on-sale rush and payment recovery.
Suggested first pass: Read sections 1–5, answer each section in your own words, then use the remaining failure modes and exercises as the advanced pass.
1. A unit of inventory is sold at most once. This is the only hard invariant; everything else is a product choice.
2. Buying is two steps, hold then confirm, because payment takes minutes and the seat must not vanish while the customer types a card number.
3. A hold is a lease that expires. Expiry is how abandoned carts return seats to the pool without anyone running a cleanup for correctness.
Browsing ("142 seats left in this section") is read-heavy, cacheable, and allowed to be wrong for a few seconds. Holding is write-heavy, uncached, and must be exact. Designs that struggle on sale day almost always mixed the two, so every curious page view reads the same rows that buyers are trying to lock.
State this split early in an interview. It tells the interviewer you know where correctness lives, and it gives you licence to cache aggressively everywhere else.
Do not promise that a seat shown as available can always be bought. Under load, a "stale available" seat fails at hold time and the buyer picks another. The guarantee is that no seat is ever sold twice, not that the seat map is a perfect mirror of the truth. A candidate who tries to make the map perfect has spent the interview on the wrong problem.
The data model decides which concurrency tools you can use, so decide it before discussing locks. There are three shapes, and real products usually contain more than one.
| Model | Example | Contention unit | Availability check | Scales by |
|---|---|---|---|---|
| Unique items | Assigned seats, one specific room | one row per item | status = AVAILABLE | Sharding by event or section |
| Counted pool | General admission, "Deluxe King, 14 left" | one counter per pool | remaining > 0 | Splitting the counter, or admission control |
| Pool per date | Hotel nights, flight fares per departure | one counter per pool per night | remaining > 0 on every night in range | Partitioning by hotel and date |
| Time slots | Restaurant tables, appointments | one row per resource per slot | no overlapping reservation | Partitioning by resource |
Unique items parallelise well. Two buyers wanting different seats never touch the same row, so throughput scales with the number of seats. A counted pool is the opposite: every buyer increments the same counter, so a popular pool is a single hot row, and the on-sale for the 20,000th general-admission ticket looks nothing like the sale of seat 14C.
Ranges add a second kind of difficulty. A guest booking three nights needs capacity on three different rows claimed together or not at all. A restaurant slot has to reject overlapping time ranges rather than just equal ones. Both need the claim to be atomic across more than one row, which is why they are harder than a single seat.
"Give me any two seats together" is not a unique-item query. It is a search over rows (adjacent, same section, same price band) followed by a claim of several rows at once. If the search and the claim are separate steps, the seats found by the search are often gone by the time you claim them. Treat it as "find candidates, then try to claim; on failure, pick the next candidate set", and bound the retries.
A seat has three states: AVAILABLE, HELD (with an owner and an expiry time), and SOLD. The interesting transition is into HELD. It must be a single atomic statement that checks and claims at once. Reading the status in application code and then writing it is the bug that every double-booking post-mortem describes.
The database serialises concurrent updates to one row, and the WHERE clause is evaluated against the latest committed value. The loser gets zero rows affected and moves on. No lock is held after the statement returns, and nothing needs cleaning up if the application crashes a millisecond later.
- 1Hold. Run the conditional update for each seat in the cart. If any returns zero rows, release the ones already claimed and tell the buyer which seat was lost.
- 2Create the order in
PENDING, linked to the held seats, with an expiry that matches the hold. - 3Charge through the payment provider, using an idempotency key derived from the order so a retry cannot charge twice.
- 4Confirm. Flip the seats to
SOLDwhere they are stillHELDby this cart, and mark the orderPAIDin the same transaction. - !If confirm affects fewer rows than expected, the seat was reclaimed after expiry: refund and tell the buyer. Never keep money without delivering the seat.
Look at the confirm statement: it does not check hold_expires_at. An expired hold only means another buyer is now allowed to take the seat. If nobody did, and the original buyer's payment lands a minute late, the seat is still theirs and the sale should go through. Rejecting it would mean charging a customer and then refusing a seat nobody else wanted. That is the worst outcome available, and a rule that only checks the clock produces it.
| Approach | How | Strength | Weakness | Use when |
|---|---|---|---|---|
| Conditional UPDATE | Check and claim in one statement | Simplest, holds no lock after return, crash-safe | Needs a store with atomic conditional writes | Default for single seats |
| Pessimistic lock | SELECT … FOR UPDATE | Easy to reason about across several rows | Held across round trips; stalls under contention | One operation must touch many rows |
| Optimistic version | Write only if version unchanged, retry | No waiting when contention is low | Retry storms on one hot row | Contention is rare and rows are wide |
| Atomic decrement | remaining = remaining - 1 WHERE remaining > 0 | One statement, no retry loop | Single hot row serialises all buyers | Counted pools |
| Redis hold, DB truth | SET NX EX to hold, DB decides | Cheap rejection of obvious losers | Two systems must agree | Rush traffic against a few rows |
Default to the conditional update. It is the smallest thing that is correct, and the confirm step uses the same idea. Reach for a pessimistic lock when one operation must touch several rows and cannot be written as a single statement, and keep the transaction short: lock, check, write, commit, with no network call to a payment provider while locks are held.
Holding seats in Redis with a TTL is fast, but if the key is the only record, a failover or eviction can lose holds and hand the same seat to two buyers. Treat the cache as a way to reject obvious losers cheaply, and let the database conditional update make the final decision. When the two disagree, the database wins, and the cache entry is repaired.
Ten thousand buyers read version 41, all attempt to write 42, one wins, 9,999 retry, and again. Work grows quadratically while sales stay linear. For a counted pool under a rush, use the atomic decrement, or split the counter across several rows (see the rush section) rather than version checks.
"Use SERIALIZABLE" is a tempting one-line answer, and it is correct in the sense that the database will abort one of two conflicting transactions. The cost is aborted transactions the application must retry, and throughput that drops as contention rises. A conditional update in a default isolation level gets the same safety for this invariant with no retry loop, because the invariant lives in one row.
A hotel does not sell "a room". It sells a room type for a range of nights, and capacity is tracked per night. Storing one row per room type per night makes each night a plain counter, and a booking becomes "claim one unit on every night in the range, or none".
One statement over all the nights keeps the claim atomic. If any night is full it updates fewer rows than expected, and you roll back, leaving nothing half-booked. Checking out on the 14th means the last night is the 13th: off-by-one errors on the end date are the most common real bug in this code, so test the boundary explicitly.
When you cannot use a single statement, lock the night rows one by one. Two bookings that lock overlapping nights in opposite orders deadlock. Always lock in date order, in every code path, so any two bookings acquire shared rows in the same sequence and one simply waits.
Time slots (a restaurant table from 19:00 to 21:00) are a different shape. The invariant is "no two reservations on this table overlap", and a plain equality check cannot express it. PostgreSQL can enforce it directly with an exclusion constraint over a time range, which turns the invariant into something the database rejects rather than something the application hopes to maintain.
Hotels and airlines knowingly sell more than physical capacity because some guests cancel or do not show. Model it by putting the sellable limit in the capacity column, separate from physical rooms, so the concurrency code never changes. What differs is the compensation process for when too many guests do arrive: moving them, upgrading them, or paying them. The engineering point is that the oversell limit is data, tuned from cancellation history, not code.
Abandoned carts leave seats in HELD. There are two ways to release them, and you want both for different reasons.
| Mechanism | What it does | If it stops working |
|---|---|---|
| Lazy expiry | The claim statement treats an expired hold as available | Nothing breaks. Correctness never depends on a job running |
| Sweeper job | Periodically flips expired holds back to AVAILABLE | Seat maps and counts look wrong until it recovers; sales still correct |
| Event on payment failure | Releases immediately when a payment is declined | The hold lingers until natural expiry. Slower, still correct |
Design it so the sweeper is a cleanup for display and metrics, never a correctness mechanism. If the sweeper is the only thing releasing seats, a stalled sweeper means an event that looks sold out and is not, which is lost revenue that nobody pages anyone about.
Short holds return seats quickly but make slow payers lose seats at checkout, which feels unfair and creates support tickets. Long holds let a small number of abandoned carts starve a popular event. Many sites show a visible countdown and allow one extension. The number is a product decision; the design only has to make it a config value, ideally adjustable per event during an on-sale.
Nothing stops a script or an impatient user from holding hundreds of seats, each valid for ten minutes. Cap holds per account and per payment instrument, and count a buyer's active holds before granting another. This is both an inventory fairness rule and a defence against scalpers.
A popular on-sale is a traffic spike aimed at a handful of rows. With 50,000 seats and a million visitors, scaling the database for the peak means paying for capacity you use for ten minutes a year. The usual answer is to control admission instead of absorbing everything.
- 1Visitors arrive at a static, cacheable page served from a CDN. It costs almost nothing per request, so the million are cheap.
- 2Each gets a queue position (randomised among those who arrived in the opening window, so refresh-spamming does not help).
- 3An admitter releases tokens at a rate the inventory store and payment provider can sustain, for example a few thousand per minute.
- 4Hold requests require a valid token, checked at the edge before they reach inventory.
- ✓The backend sees a steady stream sized for it, regardless of how many people are waiting.
- Shard by event. Different events never contend, so one big on-sale does not slow another. Within one event, partition seats by section so a hot section does not block the rest.
- Split the hot counter. A general-admission pool of 20,000 can be divided into 20 sub-pools of 1,000 on different rows. A buyer is routed to a random sub-pool and tries another if it is empty. Contention drops by the number of splits, at the cost of a final stretch where the last few tickets are scattered.
- Spread the picks. If "best available" always returns the same top seat, every buyer fights over it. Choose randomly among the top few candidates so collisions drop.
- Cache the seat map. Serve availability from a cache with a short TTL, and let the hold statement be the exact check. A stale "available" simply fails at hold time with a friendly retry.
A fair queue is easy to game with scripts opening thousands of sessions. Per-account and per-payment-instrument limits, device and behaviour signals, and verification at admission matter more than queue ordering. This is an abuse problem as much as an inventory problem, and naming it is the difference between a textbook answer and an experienced one.
At 10x traffic the hot counter row fails first, then the payment provider's rate limit, then your own payment-confirmation queue. Admission control relieves all three at once, which is why it beats scaling any one of them.
The flow spans two systems, inventory and payments, with no shared transaction. It is a saga: hold, charge, confirm, with a compensating release or refund when a step fails. The mechanics are covered in Sagas, Outbox & CDC, and the double-charge protection in Idempotency & Exactly-Once. Here is how they apply to a booking.
| Failure point | State left behind | Recovery |
|---|---|---|
| Hold succeeds, user abandons | Seat HELD, order PENDING | Hold expires; order marked EXPIRED |
| Charge fails (declined) | Seat HELD, no money moved | Release seat immediately, let the buyer retry another card |
| Charge times out (unknown) | Seat HELD, money may have moved | Ask the provider by idempotency key; do not release until known |
| Charge succeeds, process dies before confirm | Seat HELD, money taken, order PENDING | Reconciliation finds paid-but-unconfirmed orders; confirm or refund |
| Confirm finds seat reclaimed | Money taken, seat gone | Refund automatically and notify the buyer |
A timeout means "unknown", not "declined". If you release the seat on timeout and the charge actually went through, you have taken the buyer's money and sold their seat to someone else. Keep the hold until the provider confirms the outcome by key or webhook, and extend the hold while a payment is in flight so the clock does not run out mid-charge.
The dangerous window is between a successful charge and the confirm write. A scheduled job that finds orders still PENDING with a successful charge, and either confirms the seat or refunds, is what turns a rare crash into a delayed email instead of a stolen payment. Count how many it repairs each day; the number should be small and boring, and a rise tells you something upstream broke.
These are patterns visible from the outside in products people use, not claims about any company's internals.
| Product behaviour you can observe | Design idea behind it |
|---|---|
| Big concert on-sales put you in a numbered queue | Virtual waiting room with admission tokens |
| Seat map shows a countdown after you pick | Hold as an expiring lease, with the clock made visible |
| "Someone else just booked this" after you click | Stale available on the map, exact conditional claim at hold time |
| Airlines and hotels say a booking may be moved or bumped | Deliberate overbooking with a compensation process |
| Hotel search shows "only 2 left at this price" | Per-night counters plus a cached availability view |
| Refund arrives after a failed booking, minutes later | Saga compensation, run by a reconciliation job |
Failure modes worth naming
- Inventory store failover during the sale. Holds written but not replicated are lost. Use synchronous replication for the inventory primary, and accept the latency, or accept a small window and rely on the unique constraint at confirm time as the last defence.
- Two sales channels, one pool. A website and a partner API each sell from separate copies and reconcile nightly. Both sell the last seat. Route every channel through one inventory service, or allocate each a fixed block.
- Clock skew between app servers. Compute expiry with the database's
now(), not the application server's clock, so every node agrees on whether a hold has expired. - Cancelled-then-reopened inventory. A refund releases a seat back into a pool that other buyers are racing for. Release through the same atomic path as expiry, never by an unconditional write.
Check and write are separate steps, so both buyers read AVAILABLE before either writes. Make the claim one atomic conditional statement (UPDATE … WHERE status = 'AVAILABLE') and treat zero rows affected as losing the race.
If the check involves several rows, either express it in one statement or take row locks in a fixed order inside a short transaction.
If nobody else reclaimed the seat, confirm it: expiry only permits others to take it. If someone did, the confirm statement affects zero rows, so refund the charge and tell the buyer. Never keep money without delivering the seat.
This is why the confirm statement checks owner and state but not the clock.
Do not size it for a million. Admit buyers through a waiting room at the rate the inventory store and payment provider sustain, for example a few thousand per minute, and serve everyone else a static queue page from a CDN.
Capacity is set by the admission rate, not the crowd. At 3,000 admissions per minute, 50,000 seats take about 17 minutes to be fully contested, which is a comfortable sale rather than a stampede.
Keep one counter row per room type per night. Increment all three in one transaction with booked < capacity in the condition, and roll back unless exactly three rows changed.
If you must lock rows separately, lock them in date order everywhere to avoid deadlocks.
A failover or eviction loses the keys and the same seat is sold twice. Use Redis to cheaply reject obvious losers, and let a conditional write in the database make the final decision.
It is correct, but it aborts conflicting transactions and the application must retry them, and throughput falls as contention rises on a hot event. A conditional update achieves the same safety for this invariant with no retry loop, because the invariant lives in a single row.
Holds belong to a cart, not a request. Make the claim idempotent on cart and seat: if this cart already holds the seat, treat the second request as success and refresh the expiry. Otherwise the buyer fails against their own hold.
The single counter row is the bottleneck. Split the pool into sub-pools on separate rows and route buyers randomly, so contention divides by the number of splits. Add a waiting room so the rate into the store is bounded.
Accept that the last tickets scatter across sub-pools, and fall back to checking the others when a buyer's sub-pool is empty.
A timeout means unknown. If the charge went through, you have taken money and sold the seat to someone else. Keep the hold while payment is in flight, query the provider by idempotency key, and decide only once you know.
One owner of inventory. Either route every channel through a single inventory service that does the atomic claims, or allocate each channel a fixed block of seats that it owns exclusively. Two systems selling the same pool and reconciling later is how double-selling starts.
Limits per account, card, and device; holds per buyer capped; verification at queue admission; and signals such as session behaviour and account age. No single control works, and attackers adapt, so treat it as ongoing defence rather than a feature.
Create a table of 100 seats and an HTTP endpoint (or plain function) that holds a seat using a single conditional UPDATE. Write a test that starts 200 concurrent buyers all targeting 10 seats and asserts that exactly 10 holds succeed and no seat has two owners.
Then break it on purpose: replace the conditional update with a read followed by a write, rerun the test, and watch it fail. The failing run is the lesson, because it shows how small the window is and how reliably load finds it.
A chain sells 400 hotels, each with several room types, across 365 days of bookable nights. Define the schema, the claim statement for a multi-night stay, how the oversell limit is stored, and how search results show availability without hammering the booking tables.
Then add a flash sale that sends 100x normal traffic to ten hotels for one hour. Say which component fails first, where you place admission control, and what the guest sees when their night fills between search and booking.
Support reports 40 customers charged for tickets they never received. List every sequence of events, across inventory, the order service, and the payment provider, that can produce a charge without a confirmed seat. Include timeouts, a restart between charge and confirm, a late webhook, and a hold that expired and was reclaimed.
For each path, state the detection (what query or alert finds it) and the repair (confirm, refund, or release). Then describe the reconciliation job's schedule, what it must be idempotent against, and the metric you would graph to know it is working.