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.
On this page
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
Design a hotel booking site, something where people book hotels and holiday homes.
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?
Both kinds. Instant booking only for now.
How many properties, and how many bookings a day? And how much do people browse before they book?
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.
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.
Inventory is stored as one row per room type per night, for 365 nights ahead. How much space do those rows take?
- 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,000,000 × 2 = 2 million room types
- 2,000,000 × 365 = 730 million rows
- 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.
500,000 bookings a day, 100 searches per booking, and a peak of 5 times the average. How many searches per second at peak?
- 500,000 bookings a day
- 100 searches per booking
- Peak is 5 times the average
- A day is 86,400 seconds
Show the working
- 500,000 × 100 = 50 million searches a day
- 50,000,000 ÷ 86,400 ≈ 580, call it 600 a second
- 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.
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
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}/cancelThe 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.
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
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.
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.
request and replyasynchronous message
- 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.
- 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.
- room type 42 free
- 0, 1, 0
- reservation 88001
- held
3She books one room of type 42. One transaction adds 1 to
reservedon each night where it stays within the limit. 3 rows change, so it commits with a 15-minute hold.- 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.
- reservation 88001
- paying
5Ana pays. The reservation moves to
payingwith its hold extended, and the card is authorized with 88001 as the idempotency key.- reservation 88001
- confirmed
6The authorization succeeds and the reservation becomes
confirmed. Inventory does not change: the hold already counted the nights.- 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.
- 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.
- reservation 88002
- expired
- type 7 free
- +1, +1
9A sweeper in the booking service runs every minute. One transaction flips 88002 from
heldtoexpiredand subtracts 1 from both nights.
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.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Nightly counts, conditional update Usual default | One 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 check | Store 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. |
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.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Query the inventory database | Always 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 after | The 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 default | The 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 dies | What happens | What you do |
|---|---|---|
| A search index node | Its shard answers from a replica. | Run two or more replicas per shard. |
| The change stream consumer | The 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 primary | Bookings stop until a replica takes over. Search keeps working. | Replicate synchronously, so an acknowledged booking survives the failover. |
| The payment processor | Guests cannot pay; holds near expiry. | Extend unexpired holds; retry with the same idempotency key. |
| The booking service, mid-payment | The 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 sweeper | Expired holds keep rooms hidden: lost sales, not double sales. | Restart it; alert on the age of the oldest expired hold. |
| The CDN | 6,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
| Level | What the answer does |
|---|---|
| Junior | Puts search and booking on one database and checks "is the room free?" before inserting, adding a transaction when prompted. |
| Mid | Models inventory per night, claims the whole stay in one transaction, adds holds, an idempotent payment and a CDN for listings. |
| Senior | Sees 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. |
How does search know which hotels are free on those dates?
It joins the listings table with the bookings table and filters out anything with an overlapping booking.
How does search know which hotels are free on those dates?
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.
Why not keep the free count per night in the index?
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.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".