A hotel booking system is two systems that share one source of truth. The search path is read-heavy, cache-friendly and can live with availability that is a few seconds stale; the booking path is low-volume but must never sell the same room-night twice. A strong interview answer models inventory per room type (or listing) per night, makes the reserve step atomic in the database, makes every write idempotent, and confirms a booking only after a short-lived hold survives the payment step.
1. Clarify the requirements
Pin down scope first. A reasonable functional list:
- Search by location, check-in and check-out dates and guest count, with price and amenity filters and a map view.
- View a property or listing: photos, nightly prices, and a calendar of open dates.
- Book a stay and pay; cancel under the property's policy.
- Manage supply: hotels or hosts set inventory, rates and blocked dates.
Non-functional requirements, in priority order: no double bookings, fast search, high availability for browsing, and a booking flow that may take a couple of seconds. Say explicitly that you favor consistency on the booking path and availability on the search path. Leave out reviews, messaging and recommendations.
Then ask the question that changes the data model: are we selling hotel rooms or unique listings?
| Hotel chain | Airbnb-style marketplace | |
|---|---|---|
| What a guest books | A room type (any one of N identical rooms) | One specific listing |
| Inventory per night | A count, such as 40 king rooms | Free or taken |
| Overbooking | Sometimes allowed by policy | Not acceptable |
| Who edits supply | The property's own staff and systems | Many independent hosts, at any time |
| Confirmation | Usually instant | Instant, or after host approval |
Also ask whether guests pay at booking or at the property, and whether one booking can include several rooms.
2. Back-of-the-envelope numbers
The ByteByteGo hotel reservation walkthrough sizes a chain of 5,000 hotels and 1 million rooms. At 70% occupancy and an average stay of three nights, that is roughly 240,000 reservations a day, or about 3 bookings per second. Its funnel assumes around 300 QPS on hotel and room detail pages, 30 QPS reaching the booking page and 3 QPS actually reserving.
Two conclusions follow:
- Reads dwarf writes. Detail views outnumber bookings by about two orders of magnitude, so the read path needs indexes and caches.
- Inventory is small and bounded. One row per hotel, room type and night, two years ahead, is about 73 million rows (5,000 × 20 room types × 730 days). The walkthrough notes that a single database can hold that, replicated for availability.
An Airbnb-style marketplace has more listings, but the shape holds: the hard part is correctness, not throughput.
3. Split the search path from the booking path
| Search path | Booking path | |
|---|---|---|
| Traffic | Very high and spiky | Low |
| Store | Search index (Elasticsearch or OpenSearch) plus caches | Relational database, the source of truth |
| Consistency | Eventual; seconds of lag is fine | Strong, transactional |
| Worst acceptable failure | Shows a room that was just taken | Rejects a booking; never oversells |
On the search side, index each property or listing as a document with a geo_point location, capacity, amenities, price and a compact availability summary (open nights, or rooms left per night). A map view becomes a geo_bounding_box filter combined with date, guest and price filters, then ranking. The Google Maps walkthrough compares the geospatial indexes underneath (geohash, S2, H3, quadtrees).
Keep the index fresh by streaming changes out of the booking database (change data capture or a transactional outbox) rather than having the app write to both stores. If search shows a listing booked a moment ago, the booking path catches it.
4. API design
GET /v1/search?lat=&lng=&check_in=2026-12-18&check_out=2026-12-21&guests=2
GET /v1/properties/{id}/availability?from=2026-12-01&to=2026-12-31
POST /v1/holds Idempotency-Key: <uuid>
{ propertyId, roomTypeId, rooms, checkIn, checkOut, guests }
-> { holdId, expiresAt, quotedPrice }
POST /v1/reservations Idempotency-Key: <uuid>
{ holdId, paymentMethodToken }
-> { reservationId, status }
DELETE /v1/reservations/{id}
Two details matter. Dates are nights in a half-open range, [check_in, check_out), so one guest's check-out day can be the next guest's check-in. And the hold returns a price snapshot and an expiry, so a rate change mid-checkout does not surprise the guest.
5. Data model: inventory per night
room_type_inventory
hotel_id, room_type_id, date -- composite primary key
total_inventory -- rooms sellable that night
total_reserved -- held + confirmed
version -- for optimistic locking
reservation
reservation_id -- PK; doubles as idempotency key
guest_id, hotel_id, room_type_id, rooms
check_in, check_out -- [check_in, check_out)
status -- HELD | CONFIRMED | CANCELED | EXPIRED
hold_expires_at, total_price, currency, payment_id
Pre-create inventory rows for a fixed window ahead with a daily job (the ByteByteGo design uses two years). A three-night stay touches three rows. Assign the physical room at check-in, not at booking, so inventory stays a simple count. Nightly prices live in a separate rate table.
For unique listings, keep one row per listing per night, or store each reservation as a date range and let the database reject overlaps (next section). Treat status as a small state machine, HELD → CONFIRMED → CANCELED or HELD → EXPIRED, and make each transition a conditional update such as WHERE status = 'HELD', so a late payment webhook and the expiry job can never both win.
6. Preventing double booking
“Double booking” is really two different bugs:
- The same request arrives twice — a double click, a mobile retry, a gateway timeout. The fix is idempotency.
- Two guests want the same last room or the same nights. The fix is an atomic reserve step.
Idempotency for retries
The client generates a random key (a UUID v4 is fine) per checkout attempt and sends it with every POST. Store it as the reservation's primary key or a unique column, so a duplicate insert hits the constraint and the server returns the original result. Stripe's API (v1) is a good model: it saves the first response for a key, returns it on retries, errors if a retry's parameters differ, and may prune keys once they are at least 24 hours old.
Atomic reserve for races
For hotel room types, one conditional update per stay does the job:
BEGIN;
UPDATE room_type_inventory
SET total_reserved = total_reserved + :rooms
WHERE hotel_id = :hotel AND room_type_id = :room_type
AND date >= :check_in AND date < :check_out
AND total_reserved + :rooms <= total_inventory;
-- rows updated != number of nights? ROLLBACK, return 409
INSERT INTO reservation (...) VALUES (..., 'HELD', ...);
COMMIT;
Under the default isolation levels in PostgreSQL and MySQL, the update locks each row it changes, so two requests for the last room serialize: the second waits, re-checks the capacity condition against the committed value and updates fewer rows than there are nights. If overbooking is allowed, the check becomes a policy limit; the ByteByteGo version uses total_reserved + n <= 110% × total_inventory.
For unique listings, PostgreSQL can enforce “no overlapping stays” directly. Its range-types documentation shows the idea with a room-reservation table: install the btree_gist extension and declare EXCLUDE USING GIST (room WITH =, during WITH &&). Adapted to listings, with a WHERE clause (a partial exclusion constraint, allowed by CREATE TABLE) so canceled and expired rows stop blocking dates:
CREATE EXTENSION btree_gist;
CREATE TABLE booking (
reservation_id uuid PRIMARY KEY,
listing_id bigint NOT NULL,
stay daterange NOT NULL, -- [check_in, check_out)
status text NOT NULL,
EXCLUDE USING GIST (listing_id WITH =, stay WITH &&)
WHERE (status IN ('HELD', 'CONFIRMED'))
);
Two rows conflict only if they share a listing and their ranges overlap. Because daterange is half-open by default, a stay ending on the 21st and one starting on the 21st do not collide. A conflicting insert fails with a constraint error you map to “those dates were just booked.”
| Approach | Good for | Watch out for |
|---|---|---|
Pessimistic lock (SELECT … FOR UPDATE) | Easy to reason about | Holds locks; lock nights in date order to avoid deadlocks |
| Optimistic lock (version column, retry on miss) | Low contention, no locks held | Retries pile up when many guests want the same nights |
Conditional update (capacity check in the WHERE) | Simplest; one statement in any SQL database | Writers to the same hot rows still queue |
| Exclusion constraint | Unique listings and date ranges | PostgreSQL-specific; needs btree_gist |
Redis lock (SET key NX with expiry) | A fast hint in the UI | Never the source of truth; Redis's own docs steer you to Redlock for locking |
Because booking volume is low, the ByteByteGo walkthrough judges optimistic locking and a database constraint both good fits, and advises against pessimistic locking for deadlock and scalability reasons.
Never let a cache approve a booking. If your answer to “what stops two people getting the same room?” involves a cache, expect the interviewer to push until it breaks. The database that owns inventory makes the call.
7. Holds, the payment hand-off and expiry
- Hold. Run the atomic reserve and insert the reservation as
HELDwith an expiry a few minutes out. - Pay. Call the payment provider with an idempotency key derived from the reservation ID, outside the database transaction. Never hold row locks across a network call.
- Confirm. On success, flip
HELD → CONFIRMEDand write abooking.confirmedevent to an outbox table in the same transaction. Consumers send the email, notify the host and update the search index. - Release. On payment failure, or when a sweeper job finds an expired hold, flip
HELD → EXPIREDand give the nights back in the same transaction. - Reconcile. The nasty edge: payment succeeds after the hold expired. If the nights are still free, re-reserve and confirm; otherwise void or refund automatically. A periodic job compares your records with the provider's.
Mention authorization versus capture. Stripe's documentation uses hotels as its example of authorizing before arrival and capturing at check-out, but an online card authorization is usually valid for only about 7 days (windows vary by card network) unless you use an extended authorization. For a stay booked months ahead, you either charge at booking or save the payment method and charge closer to the date. There is no two-phase commit with an external provider; the flow is a saga with compensating actions (void, refund). The payment system walkthrough covers the ledger and reconciliation side.
8. Caching, consistency and scaling
- Static and listing content. Photos on a CDN; property details in a cache, invalidated on edits.
- Availability cache. The ByteByteGo design keys Redis by hotel, room type and date, with rooms left as the value, updated asynchronously through change data capture (for example Debezium). Use it to grey out calendar dates. The distributed cache walkthrough covers invalidation and hot keys.
- Sharding. When one database is no longer enough, shard by
hotel_idorlisting_idso a booking's rows live on one shard and the transaction stays local. The ByteByteGo example spreads 30,000 QPS over 16 shards, about 1,875 QPS each. - Service boundaries. Keep inventory and reservations in one database even if everything else is split into services; that avoids distributed transactions on the one path that needs ACID.
- Hot dates. Holiday weekends concentrate contention on a few rows. If optimistic retries start thrashing, switch to the conditional update. A virtual waiting room is rarely needed unless inventory goes on sale at one announced moment; the Ticketmaster walkthrough covers that case.
| Data | Consistency | Why it is acceptable |
|---|---|---|
| Search results | Eventual (seconds) | Booking rechecks |
| Calendar on a listing page | Near real time, from cache | Only a hint |
| Inventory and reservations | Strong (ACID) | Correctness |
| Emails, host alerts, analytics | Eventual, via events | Safe to retry |
9. Pacing the round and common follow-ups
Round length varies by company; if you have about 45 minutes, one workable split is 5 minutes on requirements, 5 on numbers and APIs, 10 on the data model and the search/booking split, 15 on double booking and payments, and the rest on scaling. Likely follow-ups:
- “A host blocks dates mid-checkout.” Blocks use the same atomic path; a block is just a reservation of another type.
- “The payment call timed out. Did we charge?” Retry with the same idempotency key and read the original result.
- “The price changed between search and checkout.” Honor the snapshot on the hold until it expires.
- “How would you go multi-region?” Give each property a home region for writes and replicate the read models everywhere.
Rehearse the section 6 deep dive out loud until you can explain the race, the fix and the trade-off in under two minutes; a free AI mock interview in CoPilot Interview can ask system design questions aloud and score your spoken answer.
Rehearse this design before the real round
Paste a job description and your résumé, set the mix to system design, and answer out loud; every answer is scored with what worked and what to improve. The free plan gives you 15 minutes a day with no credit card.
Try a free AI mock interviewFAQ
How do you prevent double booking in a hotel reservation system?
Make the reserve step atomic in the database that owns inventory: a conditional update that succeeds only if every night still has capacity, an optimistic version check, or an exclusion constraint that rejects overlapping date ranges. Add idempotency keys so retries and double clicks create one reservation, not two.
Should room availability be cached?
Yes for search results and listing pages, where a few seconds of staleness is acceptable, but never as the source of truth. The booking path always rechecks and updates inventory in the database inside a transaction.
How is designing Airbnb different from designing a hotel booking system?
A hotel sells interchangeable rooms of a type, so inventory is a count per room type per night. An Airbnb-style listing is unique, so each night is simply free or taken, which suits a date-range constraint. Search, holds and payments work largely the same way.
Should I use pessimistic or optimistic locking for bookings?
Booking writes are low volume, so optimistic locking or a database constraint usually works well without holding locks. Pessimistic locking with SELECT ... FOR UPDATE is safe but needs consistent lock ordering to avoid deadlocks.
How does payment fit into the booking flow?
Place a short-lived hold on the inventory, call the payment provider with an idempotency key, and confirm the reservation only after payment succeeds. If payment fails or the hold expires, release the nights; if payment succeeds after expiry, re-check availability or refund automatically.