Skip to content
Episode 40 · The last room in Porto, sold twice

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.

30 min advancedInventory holds & overbooking controlSeparating read and write pathsTransactions & ACIDCachingIdempotencyPartial failures
  1. 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.

  2. 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.

  3. 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.

  4. Technology

    Search index + availability cache, PostgreSQL inventory with conditional updates, hold TTLs, a payment provider with idempotency keys, a channel manager, a reconciliation job.

  5. Trade-offs

    Freshness vs search speed, hold length vs lost sales, pay-before vs pay-after hold, buffer allotments vs overbooking risk.

01

The problem

2 min
Monday, 09:00. The Saturday sale postmortem.
  1. Karim · CTO
    Saturday: primary at 95% CPU, Porto's last room sold twice, one guest charged with no booking. Next sale in five weeks, planned for 10×.
  2. Lina · Senior engineer, your mentor
    You lead this one. I'll only check your answers.
02

The big idea

4 min
Search can lie a little. Booking can't.
The big idea

Search 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.

03

How it works

5 min

The 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 .

From search to confirmed booking
  1. 1
    Search

    Hotels from the search index, rooms and prices from the availability cache. No load on the inventory tables.

  2. 2
    Hold

    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.

  3. 3
    Pay

    Authorise outside any transaction, with an built from the hold id: a unique id so a repeated request has no extra effect.

  4. 4
    Confirm

    Short transaction: held → sold, the booking row and an outbox event.

  5. 5
    Propagate

    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.

04

Watch a decision being made

4 min
Worked example

You 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%.

  1. 1Estimate the two paths

    Two "never" requirements point at the hold and payment; search only has a speed target.

    text
    search:   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 cheap

    Why: 16,000 lookups/s explains Saturday's 95% CPU; 2.5 bookings/s says strictness costs almost nothing where it's needed.

  2. 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.

  3. 3Make the hold atomic

    UPDATE … SET held = held + 1 WHERE … AND total - held - sold >= 1 per 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.

  4. 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.

The whole thing in one line: Search on an event-fed cache, an atomic hold for the 2.5 bookings/s, payment outside the transaction with an idempotency key and recovery, and reconciliation as the last net.
05

In real life

3 min
Instant confirmation vs host approval

Booking.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.

06

What goes wrong

3 min
Booking against the cached availability

Two guests both see "1 left" and both get confirmed.

Instead

Cache for search only; take the room atomically in the database.

SELECT rooms left, then UPDATE in app code

Another transaction takes the room in between: a .

Instead

One conditional UPDATE, checking rows affected.

Assuming overbooking can be engineered to zero

Other sites learn of our sale up to two minutes later.

Instead

Shrink the window, and set a business policy for the rest.

07

Trade-offs

3 min
FlowFits whenYou pay
Hold, then payScarce rooms; payment can take minutes (3-D Secure)Rooms frozen during checkout; a sweeper
Pay, then bookPlenty of inventory, rare sell-outsRefunds when the room is gone
Pay at the hotelFlexible rates, markets expecting itNo-shows cost the hotel
Monday, 15:00. The overbooking window.
  1. Karim · CTO
    Publish every room to every site. An empty room is lost money forever. If we overbook, the hotel walks the guest.
  2. Omar · On-call SRE
    Walking 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.
  3. Karim · CTO
    Then that last room sometimes stays empty.
08

Your job as tech lead

4 min

Claude 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.

Decisions you own
  • 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.
Tell Claude Code
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.
Check before you accept
  • No transaction in the diff contains the payment call.
  • The availability check is inside the UPDATE … WHERE, not a SELECT then UPDATE.
  • The race test passes repeatedly, and the timeout branch keeps the hold and reuses auth-{holdId}.
Red flags
  • 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.
09

Check yourself

2 min
Q1/3Debugging
The code runs SELECT 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?
How confident are you?

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.

Finished?

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.
10

Technical deep dive

Hold, pay, confirm
Worked example

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.

  1. 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.

  2. 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.

    text
    search:   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 booking

    Why: 16,000 lookups/s explains Saturday's 95% CPU. 2.5 bookings/s says strictness costs almost nothing where it's needed.

  3. 3Move search to a cached copy

    Availability cache keyed hotel:room_type:date → rooms_left, price in 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".

  4. 4Make the hold atomic

    One conditional UPDATE … SET held = held + 1 WHERE … AND total - held - sold >= 1 per 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.

  5. 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.

  6. 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.

The whole thing in one line: You split a 16,000-lookups/s search path onto an event-fed cache, kept the 2.5-bookings/s reservation path strict with an atomic hold, moved payment outside the transaction with an idempotency key and a recovery job, and added reconciliation as the last safety net.
ts
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" };
  }
}
The reservation flow: hold, pay with an idempotency key, confirm. Each step has a defined way back.
DataConsistency neededWhere it livesStaleness tolerated
Hotel descriptions, photosEventualSearch index + CDNHours
Availability and prices in searchEventualAvailability cache (Redis)Seconds, 60 s at most
Inventory when reservingStrongPostgreSQL primaryNone
Booking and its paymentStrongPostgreSQL + payment providerNone (reconciled nightly)
Availability on other sitesEventualChannel manager~30 s to 2 min: the overbooking window
Match the guarantee to the cost of being wrong.
11

Interactive simulation

12

Visual diagram

Click a component

Search reads cached, denormalised data; reservation goes to the inventory database in a transaction, then to payment, then out to the channels.

13

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.

14

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.

Where to put the strictness

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.

15

Check your understanding

Q1/3Reasoning
At StayHub's 10× sale peak there are about 180 searches/s, and each search checks 30 hotels × 3 nights of availability. How many availability lookups per second is that?
lookups/s
How confident are you?
16

Practice ladder

Step 1 · Trace

Three guests, one room

Scenario Warm-up

The 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.

Step 2 · Complete

Write the payment recovery job

Code Practice

When 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 ___
}
Step 3 · Solve

Spa slots for hotel guests

Design Practice

StayHub 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.

Step 4 · Decide

Ten-minute or three-minute holds?

Decision Stretch

Maya: "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.

17

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.

0 words