Design a hotel booking engine
Monday, 09:00: during Saturday's sale the database hit 95% CPU, two guests got the same last room in Porto, and one was charged for a booking that doesn't exist. Karim wants the booking engine redesigned for 10× before the next sale.
Hundreds of people look at a room for every one who books it. Here's how a booking engine stays fast for lookers and strict for buyers.
- Problem
Thousands of guests search a hotel's rooms every minute; a few book. Never sell the last room twice, and never charge for a room you can't give.
- Why the simple system fails
Running every search against the live inventory table melts the database, while caching availability naively sells rooms that are already gone on another channel.
- Engineering concept
Search reads a cached, eventually consistent copy; reservation re-checks the source of truth inside a transaction, places a short hold, then charges. Channels sync asynchronously, so overbooking is a managed risk, not an impossibility.
- Technology
Search index + availability cache, PostgreSQL inventory with conditional updates, hold TTLs, a payment provider with idempotency keys, a channel manager, a reconciliation job.
- Trade-offs
Freshness vs search speed, hold length vs lost sales, pay-before vs pay-after hold, buffer allotments vs overbooking risk.
The problem
2 min- Karim · CTOSaturday: primary at 95% CPU, Porto's last room sold twice, one guest charged with no booking. Next sale in five weeks, planned for 10×.
- Lina · Senior engineer, your mentorYou lead this one. I'll only check your answers.
The big idea
4 minSearch can lie a little; booking can't. Serve the many searches from a copy, and decide the few bookings strictly, with payment outside any transaction.
The shop window is rearranged every few minutes and can briefly show something just sold. At the till, the clerk checks the stockroom before taking your money, and puts the item behind the counter with your name on it while you find your card.
How it works
5 minThe look-to-book ratio is about 70 to 1. Search (~16,000 availability lookups/s at a sale) reads an copy. Reserve (~2.5/s at a sale) goes to the inventory database in a short .
- 1Search
Hotels from the search index, rooms and prices from the availability cache. No load on the inventory tables.
- 2Hold
An sets a room aside for one guest for a few minutes: one conditional UPDATE per night, only if a room is left. 0 rows → show alternatives.
- 3Pay
Authorise outside any transaction, with an built from the hold id: a unique id so a repeated request has no extra effect.
- 4Confirm
Short transaction: held → sold, the booking row and an outbox event.
- 5Propagate
The event refreshes the cache and goes to the , which shares rooms with other sites: ~30 s to 2 min later.
Is overbooking now impossible? Inside StayHub, yes. But other sites learn of our sale up to two minutes late: that window shrinks, never closes, so becomes a business policy, not just a bug.
Watch a decision being made
4 minYou redesign the engine; Lina checks
For 10×: no double bookings inside StayHub, no charge without a booking, search fast at ~180/s, primary nowhere near 95%.
- 1Estimate the two paths
Two "never" requirements point at the hold and payment; search only has a speed target.
textsearch: 1.8M/day ÷ 10⁵ ≈ 18/s → sale ×10 ≈ 180/s lookups: 180 × 30 hotels × 3 nights ≈ 16,000/s → cache, not the primary bookings: 25,000/day → sale ≈ 2.5/s → strict path is cheapWhy: 16,000 lookups/s explains Saturday's 95% CPU; 2.5 bookings/s says strictness costs almost nothing where it's needed.
- 2Move search to a cached copy
Redis keyed
hotel:room_type:date → rooms_left, price, updated by outbox events, 60 s TTL as a backstop.Why: Events keep it fresh in seconds; the TTL fixes anything a lost event left stale.
- 3Make the hold atomic
UPDATE … SET held = held + 1 WHERE … AND total - held - sold >= 1per night; rows updated must equal nights. Holds expire after 10 minutes; a sweeper releases them.Why: Check and change are one statement, so the database decides who gets the last room, with locks held for milliseconds.
- 4Pay outside, recover by key, reconcile
Key
auth-{holdId}. Declined → release. Timeout → keep the hold, answer "confirming…", a recovery job asks the provider by the same key. Nightly, compare bookings with charges and the channel manager.Why: A rollback can't undo a charge at another company. The same key makes retries safe; reconciliation catches what slipped through.
In real life
3 minBooking.com: confirmed instantly
- Most stays are confirmed the moment the guest books
- Hotels keep inventory up to date, often via channel managers
Fits hotels with many identical rooms and live inventory.
Airbnb: the host may approve first
- Many listings use "request to book": the host accepts or declines
- Approval absorbs calendar mistakes, at the cost of waiting
Fits unique homes where the owner wants a say.
What goes wrong
3 minTwo guests both see "1 left" and both get confirmed.
Cache for search only; take the room atomically in the database.
Another transaction takes the room in between: a .
One conditional UPDATE, checking rows affected.
Other sites learn of our sale up to two minutes later.
Shrink the window, and set a business policy for the rest.
Trade-offs
3 min| Flow | Fits when | You pay |
|---|---|---|
| Hold, then pay | Scarce rooms; payment can take minutes (3-D Secure) | Rooms frozen during checkout; a sweeper |
| Pay, then book | Plenty of inventory, rare sell-outs | Refunds when the room is gone |
| Pay at the hotel | Flexible rates, markets expecting it | No-shows cost the hotel |
- Karim · CTOPublish every room to every site. An empty room is lost money forever. If we overbook, the hotel walks the guest.
- Omar · On-call SREWalking costs the hotel ~€150 and a one-star review, and it blames us. For small hotels, hold back the last room from slow-syncing sites.
- Karim · CTOThen that last room sometimes stays empty.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Where strictness lives: search reads a copy, booking re-checks the real inventory. The agent must never book against the cache.
- The hold length (10 minutes) and what a payment timeout means: keep the hold, show "confirming…", ask by the same key.
- How stale search may be (events + 60 s TTL) and the overbooking policy for what channel delays still allow.
Rework the reservation flow before the next sale. Plan first, no edits until I approve. The hold is one conditional UPDATE per night (held + 1 only where total - held - sold >= 1) in a short transaction, checking rows updated equals nights; holds expire after 10 minutes. Authorise outside any transaction with key auth-{holdId}. Decline: release. Timeout: keep the hold, return 202, enqueue a recovery job asking the provider by that key. Search reads only Redis. Test two requests racing for the last room.- No transaction in the diff contains the payment call.
- The availability check is inside the
UPDATE … WHERE, not aSELECTthenUPDATE. - The race test passes repeatedly, and the timeout branch keeps the hold and reuses
auth-{holdId}.
- Hold, payment and confirm in one big transaction "for atomicity".
- On timeout: "payment failed" and the room released, or a fresh idempotency key.
- Checkout trusting the cached "rooms left" to save a query.
Check yourself
2 minSELECT total - held - sold AS left …; if left > 0 it runs UPDATE inventory SET held = held + 1 …. Twice a week the last room is double-booked. Why?Next episode, new module: StayHub has a dozen services, Booking calls five of them after every booking, and one bad morning at Loyalty breaks checkout. Time to meet Kafka.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Why it matters, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You redesign the engine; Lina checks
Redesign StayHub's booking engine for the 10× plan before the next sale: no double bookings inside StayHub, no charge without a booking, search fast at ~180 searches/s, and the primary nowhere near 95% CPU.
- 1Pin the requirements
Functional: search, hold, pay, confirm, cancel, sync with other sites. Non-functional: search p95 < 300 ms; never double-sell inside StayHub; never charge without a booking (or refund automatically); availability on other sites within ~2 minutes.
Why: Two "never" requirements point straight at where strictness is needed: the hold and the payment step. Search only has a speed target.
- 2Estimate the two paths
Search: 1.8M/day ≈ 18/s, ~180/s at a sale × 90 cells ≈ 16,000 lookups/s. Bookings: 25,000/day ≈ 0.25/s, ~2.5/s at a sale.
textsearch: 300k guests × 6 = 1.8M/day ÷ 10⁵ ≈ 18/s → sale ×10 ≈ 180/s lookups: 180 × 30 hotels × 3 nights ≈ 16,000/s → cache, not the primary bookings: 25,000/day ÷ 10⁵ ≈ 0.25/s → sale ≈ 2.5/s → strict path is cheap ratio: 1.8M ÷ 25k ≈ 70 searches per bookingWhy: 16,000 lookups/s explains Saturday's 95% CPU. 2.5 bookings/s says strictness costs almost nothing where it's needed.
- 3Move search to a cached copy
Availability cache keyed
hotel:room_type:date → rooms_left, pricein Redis, updated by inventory events from the outbox, with a 60 s TTL as a backstop. Search never touches the inventory tables.Why: Events keep it fresh within seconds; the TTL fixes anything a lost event left stale. Staleness only costs a "just sold out".
- 4Make the hold atomic
One conditional
UPDATE … SET held = held + 1 WHERE … AND total - held - sold >= 1per night, in a short transaction; check rows updated = nights. Hold expires after 10 minutes; a sweeper releases expired holds every minute.Why: The check and the change are one statement, so the database decides who gets the last room. No lock is held beyond a few milliseconds.
- 5Pay outside the transaction, safely
Authorise with idempotency key
auth-{holdId}. Declined → release the hold. Timeout → keep the hold, answer "confirming…", and a recovery job asks the provider by the same key: charged → confirm, not charged → release.Why: A rollback can't undo a charge at another company. The same key makes any retry safe, and asking by key replaces guessing.
- 6Confirm, propagate, reconcile
Confirm in a short transaction (held → sold, booking row, outbox event). The event updates the cache and the channel manager. A nightly job compares bookings with the provider's charges and with the channel manager's records.
Why: Reconciliation catches whatever slipped through every other safeguard, such as a charge with no booking, before the guest has to call us.
export async function reserve(req: ReserveRequest) {
const hold = await inventory.placeHold(req); // conditional UPDATE, short transaction
if (!hold) return { status: 409, error: "SOLD_OUT", alternatives: await search.similar(req) };
try {
const payment = await psp.authorize({ amount: hold.total, idempotencyKey: `auth-${hold.id}` });
if (payment.status !== "authorized") {
await inventory.releaseHold(hold.id);
return { status: 402, error: "PAYMENT_DECLINED" };
}
return { status: 201, booking: await inventory.confirm(hold.id, payment.id) }; // + outbox event
} catch {
// Timeout: we don't know if the charge happened. Keep the hold; ask the PSP by key later.
await recovery.enqueue(hold.id);
return { status: 202, message: "We're confirming your booking" };
}
}| Data | Consistency needed | Where it lives | Staleness tolerated |
|---|---|---|---|
| Hotel descriptions, photos | Eventual | Search index + CDN | Hours |
| Availability and prices in search | Eventual | Availability cache (Redis) | Seconds, 60 s at most |
| Inventory when reserving | Strong | PostgreSQL primary | None |
| Booking and its payment | Strong | PostgreSQL + payment provider | None (reconciled nightly) |
| Availability on other sites | Eventual | Channel manager | ~30 s to 2 min: the overbooking window |
Interactive simulation
Visual diagram
Search reads cached, denormalised data; reservation goes to the inventory database in a transaction, then to payment, then out to the channels.
Analogy
- In a shopThe shop window, updated every few minutesIn a booking engineSearch, from a fast cached copy
- In a shopThe clerk checking the stockroom at the tillIn a booking engineReservation re-checking real inventory
- In a shopAn item behind the counter with your nameIn a booking engineA short hold while you pay
- In a shopOther shops phoning in their salesIn a booking engineTravel sites synced by a channel manager
- In a shopTwo buyers grab the last itemIn a booking engineOverbooking, handled by a policy
Where the analogy breaks: a shop sells a physical item that is gone once taken. A hotel room is sold per night, so the same room can be free on Monday and taken on Tuesday.
Why it matters
"Browse cheaply, commit carefully" shows up everywhere: plane seats, concert tickets, restaurant tables, online shop stock.
Get it wrong and you lose money either way.
Too strict, and every search hits the main database. Pages get slow, and the database falls over during a big sale. Guests leave.
Too loose, and you sell rooms you don't have. Guests arrive to no room. You pay for another hotel, and they write angry reviews.
Payment mistakes hurt too: charging without a room, or giving a room without a charge.
Strict everywhere
- Every search hits the database
- Slow pages, crashes in sales
Guests leave before booking.
Loose everywhere
- Book from the stale copy
- Rooms sold twice
Guests arrive to no room.
Fast search, strict booking
- Search uses the fast copy
- Booking re-checks real stock
Fast and correct.
Check your understanding
Practice ladder
Three guests, one room
Scenario Warm-upThe Porto hotel has 1 double room left for 12–13 June (one night). The availability cache is refreshed by events within ~2 s; the channel manager syncs to another booking site within ~2 minutes.
- 10:00:00.000. Guest A clicks Book on StayHub.
- 10:00:00.005. Guest B clicks Book on StayHub.
- 10:00:30. Guest C books the same room on another site, which still shows it available.
- 10:00:40. Guest D searches StayHub for Porto.
Trace each guest: what the system does and what they see.
Write the payment recovery job
Code PracticeWhen the payment call times out, reserve() keeps the hold and calls recovery.enqueue(holdId). Complete the job that runs a minute later. It must never double-charge, never leave a charge without a booking, and never hold a room forever.
export async function recoverPayment(holdId: string) {
const hold = await inventory.getHold(holdId);
if (!hold || hold.status !== "held") return; // already confirmed or released
const result = await psp.findByIdempotencyKey(`auth-${holdId}`);
// result: { status: "authorized", paymentId } | { status: "declined" } | { status: "not_found" } | timeout error
// ___ your code: decide confirm / release / retry later ___
}Spa slots for hotel guests
Design PracticeStayHub adds spa bookings: 600 partner hotels have a spa with 2–6 therapists; guests book 60-minute treatments, paid online. At the 10× plan: ~40,000 spa page views/day and ~3,000 spa bookings/day; Sunday mornings are the peak (5× average). A therapist can't be double-booked; a guest can't be charged without a slot.
Design search, hold, payment and confirmation. Reuse what fits from the room engine and say what's different.
Ten-minute or three-minute holds?
Decision StretchMaya: "During sales, popular hotels show sold out while rooms are only held. 30% of checkouts are abandoned, so those holds just sit there for 10 minutes. Make holds 3 minutes."
Facts: - Median checkout takes 90 s; banks' 3-D Secure checks can take up to ~4 minutes. - At a sale, a 30-room hotel can have ~8 rooms held at once. - If a hold expires mid-payment and someone else takes the room, the first guest's payment must be voided and they see "sorry".
Write your decision: "We chose X over Y because…, accepting the risk of…". You may propose something other than 3 or 10 minutes.
Explain it yourself
Explain why a hotel booking engine can show cached availability in search but must not book against it, and how you would prevent the last room being sold twice.