Design Hotel Booking

Search stays by place and dates, serve fast listing pages, and book several nights at once without selling a room twice: holds, payment, overbooking.

Case study30 minLesson 7 of 10
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

A guest types "Lisbon, 12 to 15 June, 2 guests", scrolls through a few hundred places, opens ten of them, and books one. Behind that is a stays marketplace: hotels list rooms, people rent out homes, and the site has to say which of them are free on those exact nights.

It looks like ticket booking with rooms instead of seats, and the rule is the same: never sell one thing twice. What differs is the shape of the inventory and the traffic. A stay covers several nights, so one booking claims several rows at once. There is no on-sale spike; instead, people search hundreds of times per booking, filtering by availability that keeps changing. The steps follow A Framework for Any Design. Try each "Your turn" box before you read on.

Clarify the problem

The first five minutes
Interviewer

Design a hotel booking site, something where people book hotels and holiday homes.

You

Is it both hotels with many rooms of one type and homes that are a single unit? And does the guest book instantly, or does the host approve each request?

Interviewer

Both kinds. Instant booking only for now.

You

How many properties, and how many bookings a day? And how much do people browse before they book?

Interviewer

About a million properties and 500,000 bookings a day. People search and look at listings a lot: call it 100 searches and 200 listing views per booking.

You

So reads outnumber bookings 300 to 1, and the booking rate is small. My priorities: never confirm two guests for the last room on a night, never charge without a booking, and keep search fast even if it is a few seconds behind. Search results are a hint; the booking step is the truth.

  • Functional: search by place, dates and guest count; view a listing with photos, prices and a calendar; book one or more rooms of a type for a date range; pay; cancel.
  • Non-functional: no night sold beyond its limit (strong consistency on inventory); search under 300 ms, allowed to lag bookings by seconds; listing pages under 200 ms; holds last 15 minutes; a confirmed booking is never lost.
  • Out of scope: reviews, host payouts, guest-host messaging, price setting, loyalty programmes.

Estimate the scale

A day has 86,400 seconds. Assume the busiest hour of the year runs at 5 times the average.

  • Bookings: 500,000 ÷ 86,400 ≈ 6 a second, about 30 at peak. One database primary handles that.
  • Searches: 500,000 × 100 = 50 million a day, which is 50,000,000 ÷ 86,400 ≈ 600 a second, about 3,000 at peak.
  • Listing views: 500,000 × 200 = 100 million a day, about 1,200 a second, 6,000 at peak.
  • Bookings stored: 500,000 × 365 ≈ 180 million a year, about 180 GB at 1 KB each.
  • Photos: 1 million properties × 20 photos × 500 KB = 10 TB, served from a CDN and stored in object storage.
Estimate: Inventory rows for a year ahead

Inventory is stored as one row per room type per night, for 365 nights ahead. How much space do those rows take?

You know
  • 1 million properties
  • 2 room types per property on average (a home is one type with one unit)
  • 365 nights ahead
  • About 50 bytes per row

Show the working
  1. 1,000,000 × 2 = 2 million room types
  2. 2,000,000 × 365 = 730 million rows
  3. 730 × 106 × 50 B ≈ 36.5 × 109 B, about 37 GB

Estimate 37 GB (anything from 10 GB to 150 GB is a good answer)

Small enough for one machine. Size is not a reason to partition here.

Estimate: Peak search rate

500,000 bookings a day, 100 searches per booking, and a peak of 5 times the average. How many searches per second at peak?

You know
  • 500,000 bookings a day
  • 100 searches per booking
  • Peak is 5 times the average
  • A day is 86,400 seconds

Show the working
  1. 500,000 × 100 = 50 million searches a day
  2. 50,000,000 ÷ 86,400 ≈ 580, call it 600 a second
  3. 600 × 5 = 3,000 a second at peak

Estimate 3 thousand/s (anything from 1 thousand/s to 10 thousand/s is a good answer)

Against 30 bookings a second, the read side needs the machines and the write side needs the care.

Your turn: Define the API

Write the endpoints for search, a listing page and booking. How do you write the dates of a stay, and what does booking return when a night has just sold out?

Compare with a model answer

Search and listings are GETs; booking POSTs a held reservation and a pay call confirms it. A stay is check-in and an exclusive check-out date, local to the property. A sold-out night returns 409.

Define the API

HTTP API
GET  /search?near=38.72,-9.14&check_in=2027-06-12&check_out=2027-06-15&guests=2&cursor=...
  200 { "results": [ { "property_id": 501, "room_type_id": 42,
                       "total_cents": 39000, "photo": "..." }, ... ],
        "next_cursor": "..." }
 
GET  /properties/{id}                        static content, cacheable for minutes
GET  /properties/{id}/availability?from=2027-06-01&to=2027-08-01
  200 { "room_types": [ { "id": 42, "nights": [ { "date": "2027-06-12",
          "free": 3, "price_cents": 13000 }, ... ] } ] }
 
POST /reservations          Idempotency-Key: 7f3c...
  body: { "property_id": 501, "room_type_id": 42,
          "check_in": "2027-06-12", "check_out": "2027-06-15",
          "rooms": 1, "guests": 2 }
  201 { "reservation_id": 88001, "status": "held",
        "held_until": "...", "total_cents": 39000 }
  409 Conflict      not enough rooms on 2027-06-14; body lists similar options
  400 Bad Request   check_out not after check_in, or more than 365 days ahead
 
POST /reservations/{id}/pay
  200 { "status": "confirmed" }
  402 Payment Required   declined; the hold stays until it expires
  410 Gone               the hold expired
 
POST /reservations/{id}/cancel

The API decision worth arguing is how to write a stay. Use two plain dates in the property's own calendar, no time, no time zone. The stay is the nights from check-in up to but not including check-out: 12 to 15 June is the nights of the 12th, 13th and 14th. UTC timestamps invite off-by-one bugs, because midnight in Lisbon is not midnight on the server.

The listing page is two calls on purpose: the static part caches well for minutes, while availability and price change with every booking.

Your turn: Model the data

Sketch the records for room types, inventory and reservations. How does one booking of three nights check and claim all three nights at once?

Compare with a model answer

A row per room type per night with a total and a reserved count. One transaction adds to every night of the stay where it fits the limit, and commits only if every night changed.

Model the data

Room types, nightly inventory, reservations
room_types
  property_id    BIGINT
  room_type_id   BIGINT
  name           VARCHAR(100)   -- "Double room, sea view"
  max_guests     SMALLINT
  total_units    SMALLINT       -- 1 for a home
  PRIMARY KEY (property_id, room_type_id)
 
inventory                        -- one row per room type per night
  property_id    BIGINT
  room_type_id   BIGINT
  night          DATE           -- local date at the property
  total_units    SMALLINT
  overbook_limit SMALLINT       -- extra units the hotel allows; 0 for homes
  reserved       SMALLINT       -- held + confirmed
  price_cents    INT
  PRIMARY KEY (property_id, room_type_id, night)
 
reservations
  reservation_id BIGINT
  property_id    BIGINT
  room_type_id   BIGINT
  user_id        BIGINT
  check_in       DATE
  check_out      DATE           -- exclusive
  rooms          SMALLINT
  status         VARCHAR(10)    -- held | paying | confirmed | expired | cancelled
  held_until     TIMESTAMP
  total_cents    INT
  idem_key       VARCHAR(64) UNIQUE
  PRIMARY KEY (property_id, reservation_id)

A hotel sells a room type, not room 214; the front desk picks the room at check-in. So inventory is a count per type per night, and a home is the same thing with total_units = 1.

A booking changes several rows of one property all-or-nothing, which points to a relational database with transactions. Every key leads with property_id, so if you partition by property later, a booking never crosses partitions. A guest's list of trips is a separate table by user_id, written after the booking commits.

Your turn: High-level design

Sketch the boxes for searching, viewing a listing and booking. Where does search get availability from, and how does it learn about new bookings?

Compare with a model answer

A search index holds listings plus their sold-out nights, fed by the inventory change stream. Listing pages come mostly from a CDN. A booking service, the only writer of inventory, also calls the payment processor.

High-level design

Searches and page views, 9,000 a second at peak, read copies that may be seconds old. Bookings, 30 a second, are the only writers of inventory.

Search and listing pages read copies that lag by seconds. Only the booking service writes the inventory primary, and every booking is checked there.Guest's browser to CDN (pages, photos); Guest's browser to API gateway; CDN to Search and listings (on a miss); API gateway to Search and listings (search, calendar); API gateway to Booking service (book, pay); Search and listings to Search index (query); Search and listings to Inventory replica (recheck); Booking service to Inventory primary (reserve nights); Booking service to Payment processor (authorize); Inventory primary to Inventory replica (replication), asynchronous; Inventory replica to Search index (change stream), asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimaryPaymentprocessorpages, photoson a misssearch, calendarbook, payqueryrecheckreserve nightsauthorizereplicationchange stream

request and replyasynchronous message

Search and listing pages read copies that lag by seconds. Only the booking service writes the inventory primary, and every booking is checked there.
Search, book three nights, lose a race, expire a holdUse the arrows or ← → keys
  1. Search, book three nights, lose a race, expire a hold, step 1Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch index412 matchesInventoryreplicaInventoryprimaryPaymentprocessor
    nights
    12, 13, 14

    1Ana searches near Lisbon for 12 to 15 June. The index filters by distance, guests and "no sold-out night from the 12th to the 14th"; the 25 results shown are rechecked on the replica.

  2. Search, book three nights, lose a race, expire a hold, step 2Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimaryPaymentprocessor
    room type 42 free
    1, 2, 1

    2She opens a hotel. Page and photos come from the CDN; only the calendar call reaches our servers, and it reads about 60 rows from the replica.

  3. Search, book three nights, lose a race, expire a hold, step 3Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimary3 of 3 rowsPaymentprocessor
    room type 42 free
    0, 1, 0
    reservation 88001
    held

    3She books one room of type 42. One transaction adds 1 to reserved on each night where it stays within the limit. 3 rows change, so it commits with a 15-minute hold.

  4. Search, book three nights, lose a race, expire a hold, step 4Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimary1 of 2: rollbackPaymentprocessor
    room type 42 free
    0, 1, 0
    Ben
    409 on night 14

    4A second later Ben, whose search ran before Ana booked, tries type 42 for 14 to 16 June. Night 14 is now full: 1 of 2 rows changes, so it rolls back and he gets 409.

  5. Search, book three nights, lose a race, expire a hold, step 5Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimaryPaymentprocessorkey 88001
    reservation 88001
    paying

    5Ana pays. The reservation moves to paying with its hold extended, and the card is authorized with 88001 as the idempotency key.

  6. Search, book three nights, lose a race, expire a hold, step 6Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimaryPaymentprocessor
    reservation 88001
    confirmed

    6The authorization succeeds and the reservation becomes confirmed. Inventory does not change: the hold already counted the nights.

  7. Search, book three nights, lose a race, expire a hold, step 7Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexlag about 2 sInventoryreplicaInventoryprimaryPaymentprocessor
    sold out
    12, 14

    7Ana's hold took nights 12 and 14 to zero free, so the change stream marks them sold out in the index, about 2 seconds later. Until then the booking check is what catches it.

  8. Search, book three nights, lose a race, expire a hold, step 8Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browsergoneCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimaryhold expiredPaymentprocessor
    reservation 88002
    held, expired

    8Carl holds type 7 for two nights, then closes his laptop. A count cannot tell which units are expired holds, so the rooms stay reserved.

  9. Search, book three nights, lose a race, expire a hold, step 9Guest's browser to CDN; Guest's browser to API gateway; CDN to Search and listings; API gateway to Search and listings; API gateway to Booking service; Search and listings to Search index; Search and listings to Inventory replica; Booking service to Inventory primary; Booking service to Payment processor; Inventory primary to Inventory replica, asynchronous; Inventory replica to Search index, asynchronous.Guest's browserCDNAPI gatewaySearch andlistingsBooking serviceSearch indexInventoryreplicaInventoryprimaryPaymentprocessor
    reservation 88002
    expired
    type 7 free
    +1, +1

    9A sweeper in the booking service runs every minute. One transaction flips 88002 from held to expired and subtracts 1 from both nights.

1 / 9

Deep dives

Reserving several nights at once

A three-night stay is three rows that must all have room. Check and claim them in one atomic step, or a guest gets a booking with a hole in it.

Ways to claim a range of nights
OptionGood atBad atPick it when
Nightly counts, conditional update Usual defaultOne statement claims every night that has room; count the changed rows. Works for many units and for one.Nightly rows to pre-create. Expired holds need a sweeper, because a count does not say which units are stale.Room types with counts, the general case. The usual answer.
Date ranges with an overlap checkStore each booking as a range; an exclusion constraint rejects any range that overlaps another. No nightly rows.Only says yes or no, so it fits single units. It cannot express "fewer than 30 overlaps" for 30 rooms.A product of one-unit homes only.
Claim one room of type 42 for 12 to 15 June
BEGIN;
UPDATE inventory
   SET reserved = reserved + 1
 WHERE property_id = 501 AND room_type_id = 42
   AND night >= '2027-06-12' AND night < '2027-06-15'
   AND reserved + 1 <= total_units + overbook_limit;
-- rows changed must be 3 (the nights 12, 13, 14). Fewer: ROLLBACK, return 409.
INSERT INTO reservations (..., status, held_until)
     VALUES (..., 'held', now() + interval '15 minutes');
COMMIT;

Why it is safe: an updated row stays locked until commit, so a second transaction that wants it waits. In databases such as PostgreSQL at read committed, the waiting update then re-checks its WHERE clause against the new value, sees the night is full and skips it, so the row count is short and you roll back. At stricter isolation levels it fails with a serialization error instead, which also becomes a 409 (see transactions and isolation). Overlapping stays can lock shared nights in opposite orders and deadlock; the database aborts one, and you retry it once or lock nights in date order first.

Searching by availability

"Places free on 12 to 15 June" is the hard query, because availability changes with every booking.

Where search gets availability
OptionGood atBad atPick it when
Query the inventory databaseAlways exact.3,000 searches a second, each scanning thousands of listing-nights, on the database that takes bookings.A small site with a few thousand listings.
Index without availability, check afterThe index stays simple and never needs updating on a booking.In a busy week most top results are full, so you fetch and drop page after page.Availability is rarely the limiting filter.
Sold-out nights in the index, recheck the page Usual defaultThe index drops what it knows is full; the page shown is rechecked on a replica.Seconds of lag, and a change stream to run.Availability decides results and a few seconds of lag is fine. The usual answer.

Each room type's document in the search index carries its list of sold-out nights. A room type fits a stay if none of the stay's nights is in the list: one "must not match" filter. The change stream updates that field only when a night's free count reaches zero or leaves it, applied per room type in log order so a late "free" never overwrites a newer "sold out". The place is a geo filter in the same query (see Design Proximity Search).

The list only knows "at least one free", so a family wanting three rooms may see a hotel with one left. The search service therefore rechecks the 25 results it is about to show in one batched replica read: 25 room types × 3 nights = 75 rows, one query per search, 3,000 a second at peak. Results that fail are dropped.

Listing pages are the other read. The static part sits on a CDN for five minutes and is purged when the host edits it; the calendar is about 60 rows from a replica. None of the 6,000 views a second reach the primary.

Holds, payment and expiry

Reserve the nights, then charge; charging first leaves a paid guest with no room whenever the claim fails. The flow is the one from ticket booking: a hold, a paying state the sweeper never touches, and an idempotent charge (Design a Payment System handles unknown results). Two things differ.

  • Expiry needs a writer. A seat row carries its own held_until, so an expired hold reads as free. A count of 3 cannot say one of the three is stale, so the sweeper releases it: UPDATE reservations SET status = 'expired' WHERE id = 88002 AND status = 'held' AND held_until < now(), and only if that changed one row, subtract from the nights in the same transaction. The status check makes it safe to run twice.
  • Authorize now, capture later. Many stays are free to cancel until a deadline, so the booking authorizes the card, holding the funds, and captures later. Card network rules make an authorization lapse after days, so for a stay months ahead, save the card as a token and authorize nearer the date.

Overbooking on purpose and by accident

Some guests never arrive. In our scenario, a 100-room hotel with about 5 no-shows per 100 bookings may choose to sell 104. The overbook_limit column lets each hotel set this per room type, and the platform enforces it exactly. It is 0 for homes. The hotel decides, because the hotel pays when it has to send a guest elsewhere.

Accidental overbooking is the bigger risk. A hotel often sells the same rooms on several sites, which learn of each other's bookings through a sync service, with lag. Two sites can each sell the last room in the same minute, and no transaction in your database can stop it. Push your bookings out within seconds to shrink the window, and plan for the rest: a relocation process that finds a comparable room and pays the difference.

Bottlenecks and failures

At 10x, 30,000 searches a second, the index needs work first: shard it by region, since every query names a place, and add replicas. The rechecks become 30,000 reads a second, so add inventory replicas or an availability cache fed by the same change stream. Bookings at 300 a second still fit one primary; partition by property_id when you want headroom or isolation.

When this diesWhat happensWhat you do
A search index nodeIts shard answers from a replica.Run two or more replicas per shard.
The change stream consumerThe index goes stale, so more rechecks fail and more bookings get 409.No double sales. Alert on lag; it resumes from its saved position.
The inventory primaryBookings stop until a replica takes over. Search keeps working.Replicate synchronously, so an acknowledged booking survives the failover.
The payment processorGuests cannot pay; holds near expiry.Extend unexpired holds; retry with the same idempotency key.
The booking service, mid-paymentThe card is authorized but the reservation is stuck in paying, which the sweeper skips.A reconciler asks the processor about paying reservations older than a few minutes, by idempotency key, then confirms or releases.
The sweeperExpired holds keep rooms hidden: lost sales, not double sales.Restart it; alert on the age of the oldest expired hold.
The CDN6,000 listing views a second fall through to the listing service.Keep a cache in front of the listing store sized for it.

What a strong answer sounds like

LevelWhat the answer does
JuniorPuts search and booking on one database and checks "is the room free?" before inserting, adding a transaction when prompted.
MidModels inventory per night, claims the whole stay in one transaction, adds holds, an idempotent payment and a CDN for listings.
SeniorSees that reads are the scale problem, separates a stale search copy from the exact booking check, releases counted holds safely, and treats overbooking across sites as a business process.
Availability in searchWeak answer
Interviewer

How does search know which hotels are free on those dates?

You

It joins the listings table with the bookings table and filters out anything with an overlapping booking.

Availability in searchStrong answer
Interviewer

How does search know which hotels are free on those dates?

You

The index keeps each room type's sold-out nights, updated from the change stream within seconds, and excludes anything sold out on a night of the stay. I recheck the 25 results I show on a replica, since the index lags. The booking does its own check in a transaction, so a stale result costs a retry, never a double booking.

Interviewer

Why not keep the free count per night in the index?

You

Every booking would rewrite a document, and most of those changes alter no search result. Sold-out flips are far rarer.

Follow-up questions

Prices change per night. How do you show the total?

Each inventory row has its own price, so the total is the sum over the stay's nights. Copy it onto the reservation when the hold is taken, so a price change mid-checkout does not change what the guest agreed to.

A guest cancels. What changes?

One transaction moves the reservation to cancelled and subtracts from each night, guarded by the status like the sweeper. The refund follows the property's policy through the payment system.

How would you add request-to-book, where the host approves?

The same hold, with a longer expiry, say 24 hours, and a card authorization rather than a charge. The host's accept confirms the reservation; a decline or a timeout releases the nights and the authorization.

A festival fills one city's hotels. Does the design hold?

Contention is per room type and night, so guests spread over hundreds of hotels rarely collide. The pressure is on search: many identical queries, which a short-lived cache of result pages absorbs.

A hotel takes 5 rooms out for repairs. What happens?

Lower total_units on those nights in one transaction. If reserved is now above the limit, cancel nobody automatically: the nights show as sold out, and the extra bookings go to the same relocation process as accidental overbooking.

Self-review: hotel booking0 of 13

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure