all_lessons / system_design / cases / C29 C29 / C44

Design a hotel reservation system

A room-night is a unit of inventory that exactly one guest may own at a time. The product wants browsing to feel instant and conversion to be high; correctness wants it to be impossible to sell the same room twice. Those two wants pull in opposite directions, and the design is the negotiated treaty between them.

Source: Vol. 2 Ch. 7, archive Anchor: hold state machine + CAS decrement Trade-off first
First principle — scarcity forces a serialization point
A room-night is conserved: the count cannot go below zero, and two confirmations against the last unit cannot both win. The instant you have a quantity that must not be oversold, you have introduced a place in the system that must be linearizable — a single point where the decrement happens in a defined order. Everything else (search, the catalog, prices, photos) can be stale, replicated, cached, eventually consistent. But the decrement of remaining is the one operation where two concurrent actors cannot both believe they succeeded. The whole design is an exercise in making that serialization point as small and as cheap as possible, and keeping everything else off it.

The hinge: why holds exist, and what they cost

Naively you would decrement inventory at the moment a guest clicks "book" and let payment ride on top. But payment authorization is slow and unreliable — a card auth round-trips to a processor and can take seconds, and a meaningful fraction of started checkouts are abandoned. If you decrement only on successful payment, two guests can both pass the "is a room available?" check while the first one's card is still authorizing, and you oversell. If instead you decrement before payment, you must be able to give the room back when payment fails or the user walks away.

That is the hold: a temporary, expiring claim on a unit of inventory, taken the moment checkout begins, converted to a confirmed reservation on payment success, and automatically returned to the pool on payment failure or timeout. The hold is what lets search be fast and wrong-ish while booking is slow and exactly right. It is also the source of this case's signature tension — a hold removes a room from sale before anyone has paid for it, so abandoned checkouts quietly shrink your sellable inventory.

The load-bearing calculation: hold economics
Checkout takes ~90 s end to end (form, card entry, 3-D Secure, auth round trip). Suppose 30% of started checkouts abandon — the user closes the tab, the card declines, they go comparison-shop. Each started checkout holds a room for the hold-TTL. Set the TTL too generously — say 15 minutes "to be safe" — and an abandoned hold sits dead for 15 min while the live ones clear in 90 s. The dead holds dominate: with a steady arrival of checkouts, the fraction of held inventory that is abandoned-but-not-yet-expired scales as 0.30 · (TTL / completion_time). At TTL = 15 min that is 0.30 · (900 / 90) = 3.0 — i.e. the abandoned backlog is 3× the live working set, and on a hot hotel-date you can lock up 30%+ of sellable rooms in holds that will never convert. Drop the TTL to 2 min: 0.30 · (120 / 90) = 0.40 — the abandoned backlog falls to under half the live set. The lesson: the hold TTL must be just longer than the 99th-percentile honest checkout, and not one second more. A tight TTL is the single biggest lever on effective inventory, and "sellable rooms" is not the same number as "rooms" — it is rooms minus live holds minus abandoned-not-yet-expired holds.

1. Clarify the contract

Treat the prompt as a product contract before a box diagram. The system must:

What it explicitly need not do: assign a specific physical room at booking time (hotels sell room types, not rooms, and assign the actual key at check-in); show perfectly fresh availability on the search page; or make search and booking share a consistency model. Conflating those is the classic over-build.

2. Put numbers on the shape

The headline ratio that steers everything: search vastly outweighs booking. A user runs many searches, refines dates, compares hotels, and books at most once — a realistic read/write ratio on the availability data is ~1000:1. That single fact licenses the central architectural move: serve search from a cheap, replicated, eventually-consistent read path, and reserve the expensive strongly-consistent path for the 0.1% of traffic that actually mutates inventory.

Search : booking read/write
~1000 : 1
Checkout completion time
~90 s
Checkout abandon rate
~30%
Hold TTL (target)
~2 min
Abandoned backlog @15min TTL
3.0× live set
Abandoned backlog @2min TTL
0.40× live set

Two more shape facts. (1) Inventory writes concentrate by hotel and date — a popular property over a holiday weekend is a single hot key. (2) A flash deal or a sold-out concert can turn one hotel-date into an extreme hotspot, which is exactly the regime case 30 (flash sale) lives in; it shares this case's counter mechanism and adds admission control on top. The counter for one room-type-night is small (an integer), so the pressure is not storage — it is contention on a single row. That is DDIA Ch. 6's hot-spot partitioning problem: partition by (hotel_id, room_type, date) so distinct dates and properties land on distinct shards and the hot key is at least isolated rather than dragging neighbors down.

3. Data model & API

The data model is the first architecture. Keep the inventory counter tiny and own the consistency boundary explicitly.

EntityKeyNotes
inventory(hotel_id, room_type, date)The conserved counter: total, reserved. Sellable = total − reserved − live_holds. This is the linearizable cell.
holdhold_id(inventory_key, qty, expires_at, state). Expiring claim; rolls up across the date range of a stay.
reservationreservation_idThe confirmed booking; references the hold it was promoted from. Carries a state-machine field.
paymentpayment_id + idempotency_keyAuth/capture state. Handed off idempotently (see C27).
API / operationConsistency it needs
GET searchAvailability(hotel?, dates, occupancy)Eventually consistent. Reads the denormalized index; may be slightly stale.
POST createHold(inventory_key, qty, ttl)Strong. Atomic conditional decrement against the live counter.
POST confirmReservation(hold_id, payment_token)Strong + exactly-once. Promote hold→reservation in one transaction; idempotent on retry.
POST cancelReservation(reservation_id)Strong. Return inventory; idempotent.

4. Linearized design

Walk a checkout through the system in the order events actually fire. The first bottleneck — the contended counter — surfaces naturally at step 2.

  1. Search. The guest browses. Reads hit a denormalized availability index / cache, fed asynchronously from the inventory store (lesson 06's read path). Stale by seconds is acceptable; the booking path will revalidate.
  2. Create hold. On "book," issue an atomic conditional decrement against inventory(hotel, room_type, date): "if reserved + live_holds < total, increment held by qty and write a hold row with expires_at = now + TTL; else fail." This is the only strongly-consistent write so far, and the room is now off the market.
  3. Pay against the hold. Run payment authorization. The room is already reserved by the hold, so this can take its 90 s without anyone else grabbing the unit. The payment is handed off with an idempotency key (C27) so a retry never double-charges.
  4. Confirm. On auth success, in a single transaction, promote the hold to a reservation and mark the hold consumed. This is the exactly-once boundary — it must be idempotent so a client retry or a duplicate webhook converges to one reservation.
  5. Expire. A durable scheduled sweep (or a TTL on the hold row) returns inventory for any hold whose expires_at passed without confirmation. This is the path that makes step 2 safe to take optimistically.
  6. Reconcile. A background audit cross-checks reservations, payments captured, and inventory held, catching any drift (a confirmed reservation with no capture, a hold leak the sweep missed).
HOLD → PAYMENT → CONFIRM state machine (inventory returns on every failure edge) createHold: CAS decrement ┌───────────┐ (reserved+held < total?) ┌──────────────┐ │ AVAILABLE │ ──────────────────────────▶ │ ON_HOLD │ │ (sellable)│ yes, held += qty │ expires_at= │ └───────────┘ │ now + TTL │ ▲ ▲ └──────┬───────┘ │ │ no → 409 (sold out) │ │ │ payment auth ok │ TTL elapses / auth fails │ │ ▼ │ │ │ ┌──────────────┐ │ │ └─── return inventory ───────────── │ CONFIRMED │ │ │ (cancelReservation) │ (reservation)│ │ │ └──────────────┘ │ │ │ └────────── EXPIRED / FAILED: held -= qty ◀───────────────┘ (durable sweep or row TTL) sellable(hotel,type,date) = total − confirmed − live_holds the single linearizable cell ──────────────────^^^^^^^^^^^

5. Deep dives

Deep dive 1 — stale search, strong booking: pushing correctness down the funnel

Given the 1000:1 read/write ratio, making search strongly consistent would be paying the cost of linearizability on 99.9% of traffic that never mutates anything — DDIA Ch. 9 is explicit that linearizability is expensive (it costs availability under partition and adds coordination latency). So search reads an eventually-consistent, heavily replicated index and is allowed to be a few seconds stale. The danger this creates: a guest sees "1 room left," clicks book, and the room is already gone.

The resolution is to treat search as advisory and the hold as authoritative. Search never promises the room; it promises "probably available, click to find out." The truth is established only at createHold, by the conditional decrement on the live counter. A stale search that loses the race simply returns "sold out, here are alternatives" — annoying but correct. This is the canonical move from foundation lesson 09: pick the consistency model per operation, not per system. Reads get availability and speed; the one write that matters gets linearizability. The consistency boundary is drawn precisely at createHold — everything above it is eventually consistent, everything from it down is strict.

Deep dive 2 — overbooking as policy, not as a bug

Airlines and hotels deliberately oversell, because the expected no-show rate means a strict no-oversell policy leaves revenue (empty rooms) on the table. This is a business decision that the inventory system must support, not a correctness failure to be prevented. The clean way to model it: keep the hard invariant held + confirmed ≤ total, and expose total as a policy knob — sell against total · (1 + overbook_factor), e.g. allow 105 confirmations against 100 physical rooms when historical no-shows run ~6%.

The critical distinction: overbooking is bounded, accounted, and reversible (you walk a guest to a comparable hotel and compensate), whereas a race-induced oversell is unbounded and silent — you don't know it happened until two guests show up at one door. So even with overbooking enabled, the conditional decrement against the (inflated) cap is still mandatory. Overbooking changes the number you compare against; it does not relax the atomicity of the comparison. Conflating "we allow overbooking" with "we don't need strict counting" is a senior-level red flag.

Deep dive 3 — the consistency boundary: where exactly-once actually matters

It is tempting to demand exactly-once everywhere; that is both impossible and unnecessary. Map where each guarantee is required:

The mechanism for the last bullet is taught in full in C27 (delivery semantics / idempotent commit) and foundation lesson 12 — here we only note the case-specific key: the confirm transaction is keyed by (hold_id, payment_idempotency_key), and the payment handoff uses the processor's idempotency key so the auth itself is safe to retry. DDIA Ch. 7 frames the promote-hold-to-reservation step as a write-skew-prone operation that you defend by materializing the conflict — the hold row is the materialized lock that two confirmers contend on, so the database serializes them for you instead of you reasoning about phantom reads.

6. Trade-offs

ChoiceBuysCostsChoose when
Stale search vs strong searchFast, cheap, highly available browsing on 99.9% of trafficOccasional "lost the race at checkout"Always, given the 1000:1 ratio — revalidate at hold time
Hold inventory vs confirm-onlyNo oversell during slow payment; clean UXAbandoned holds shrink sellable inventory (the TTL math)Any flow with multi-second payment — i.e. essentially always
Room type vs exact roomOne counter per type/date; far less contentionLess control; assignment deferred to check-inStandard hotel inventory
Overbook vs strict no-oversellHigher occupancy / revenue against no-showsWalk + compensation risk; needs accountingDeliberate revenue policy — not a default
CAS decrement vs serialize per keyCAS: lock-free, high throughput on warm keysSerialize: simpler reasoning, lower throughputCAS by default; serialize only the extreme hot hotel-date

The sharpest trade-off is the hold TTL, because it is where product and correctness collide on a single number. Product wants a long TTL so a dithering guest never loses their room mid-checkout; the inventory math wants the shortest TTL that covers an honest checkout, because every extra second of TTL multiplies the abandoned backlog (the 0.30 · TTL/90 factor above). The senior move is to size TTL to the p99 of real completion, not to a round "safe" number, and to release the hold the instant payment fails rather than waiting for the timer — the timer is only a backstop for the user who vanishes, not the user whose card declined.

7. Failure modes

FailureMitigation
Two confirms on the last roomAtomic conditional (CAS) decrement, or serialize per (hotel,type,date) key. The DB serializes the contended row; exactly one wins, the other gets 409.
Payment captured, reservation write failsState machine + compensation: the confirm is exactly-once-keyed (C27); a failed promote triggers a refund/void saga (lesson 12). Reconciliation catches stragglers.
Hold leak (sweep never runs)Durable scheduled cleanup and a row-level expires_at the read path respects, so a leaked hold is ignored as available even if the sweep is late.
Hot hotel-date thrashPartition by (hotel,type,date) (DDIA Ch. 6) so the hot key is isolated; for extreme spikes, queue checkout entry — which is exactly C30's admission-control machinery on this same counter.
Stale search shows sold-out roomAcceptable by design — hold-time revalidation is the source of truth; return alternatives on the lost race.

8. Interview Q&A

  1. Two users hit confirm on the last room at the same millisecond — how do you guarantee exactly one wins? (senior answer) Make the decrement a single atomic operation on the store: a conditional update — UPDATE inventory SET held = held+1 WHERE held+confirmed < total — that the database serializes on the row. Exactly one update matches the predicate; the loser's WHERE fails and gets a 409. Equivalently, a compare-and-set on a counter cell or per-(hotel,date) serialization. The read-check-write must be atomic where the data lives; doing it in application code is the textbook race that admits 101 against a limit of 100. This is DDIA Ch. 7's materialized-conflict / write-skew defense — the row is the lock.
  2. Why hold inventory before payment at all? (senior answer) Payment auth takes seconds; if you only decrement on success, concurrent checkouts both pass the availability check and you oversell. The hold removes the unit at checkout start and returns it on failure/timeout, moving the slow, flaky payment off the contended counter.
  3. How long should the hold TTL be? (senior answer) Just above the p99 of an honest checkout — ~2 min, not 15. Abandoned backlog scales as abandon_rate · TTL/completion_time; at 30% abandon and 90 s checkout, a 15 min TTL locks up 3× the live working set in dead holds, gutting sellable inventory on hot dates. And release on payment failure immediately — the timer is only a backstop for vanished users.
  4. Search says a room is free but it's gone — bug? (senior answer) No, by design. Search is eventually consistent (1000:1 read/write makes strong search wasteful); the hold-time conditional decrement is the source of truth. A lost race returns sold-out + alternatives. Correctness lives at one point, not across the whole system.
  5. Is overbooking a correctness violation? (senior answer) Not when it's policy. Inflate the cap by an overbook factor tuned to no-show rate and still enforce the conditional decrement against that cap. Bounded, accounted, walk-able overbooking is revenue management; an unbounded race-induced oversell is the bug. Overbooking changes the number compared, never the atomicity of the comparison.
  6. Payment captures but the reservation write fails — now what? (senior answer) A compensation saga (lesson 12): the confirm is keyed exactly-once (C27) on (hold_id, payment_idempotency_key); if the promote can't complete, void/refund the auth. Reconciliation audits for confirmed-without-capture and captured-without-reservation and resolves the drift.
  7. One hotel-date is a hot row under a flash deal — how do you keep it from melting? (senior answer) Partition by (hotel,type,date) so the hot key is isolated (DDIA Ch. 6), keep the CAS lock-free on the warm key, and for true spikes put a queue / admission gate in front of checkout entry — which is exactly case 30's flash-sale design layered on this same counter. Don't fan the single counter out into shards you then have to re-sum; that reintroduces the race.
  8. CAS decrement vs serializing per key — which and why? (senior answer) CAS by default: lock-free, high throughput, the conditional update is its own concurrency control. Reach for explicit per-key serialization (a queue or a lock on (hotel,date)) only on the rare extreme hotspot where you want strict FIFO fairness or simpler reasoning — that's the C30 regime, and it trades throughput for order.

Related lessons

C30 (flash sale) shares this case's inventory-counter and conditional-decrement mechanism exactly — it is this counter under extreme contention, with admission control and fairness layered on top (this case is the anchor for the hold state machine; C30 leans on foundation lesson 15's rate limiting). C27 is the anchor for the exactly-once / idempotent-commit machinery the confirm step depends on — read it for the payment-handoff key. Foundation lesson 12 (distributed transactions) gives the saga / outbox / idempotency mechanics for the payment-fails-after-confirm path, and lesson 09 (consistency & CAP) is where the per-operation consistency choice (stale search, strong booking) is derived. The hot-key partitioning is DDIA Ch. 6; the atomic decrement is Ch. 7's write-skew / compare-and-set; the cost of the strong booking path is Ch. 9's linearizability.

C30 · Flash sale (shared counter) C27 · Exactly-once / idempotency 12 · Transactions & idempotency 09 · Consistency & CAP
Takeaway
A reservation system is a study in confining linearizability to the one cell that needs it. Scarcity forces a single serialization point — the inventory counter — so make the decrement atomic (conditional update / CAS, or serialize per hotel-date) and let exactly one confirmer win the last room. Everything else stays cheap: search is eventually consistent because the 1000:1 read/write ratio makes strong search a waste, and the booking path revalidates anyway. The hold is what bridges slow payment and strict counting, but it has a price — abandoned holds shrink sellable inventory as abandon_rate · TTL/completion_time, so the TTL must be tight. Overbooking, when you want it, changes the number you compare against, never the atomicity of the comparison.