Blueprint
Geo & marketplaces intermediate

Design a hotel booking system

Three bookings a second and still a hard question: the interviewer picked tiny numbers on purpose, because this is a consistency problem wearing a scale problem's clothes.

asked at Booking.com · Airbnb · Agoda Sometimes asked ~35 min walkthrough
01

Scope the requirements

The numbers are deliberately modest. Candidates who start sharding and adding queues have already failed; the interviewer wants the inventory model, the concurrency story on one hot row, and idempotency on retries.

Functional — what it must do

  • Search hotels by location and date range; view hotel and room details.
  • Reserve a room by room type — not a specific room number — for a date range.
  • Cancel or modify a reservation.
  • Hotel admins manage inventory, room types and nightly rates.
  • Support controlled overbooking (sell up to 110% of physical inventory) as a business rule.
  • Explicitly cut: loyalty points, multi-room group bookings, channel managers — extensions if asked.

Non-functional — the numbers that shape the design

  • No double-booking beyond the overbooking allowance — the consistency guarantee the whole design serves.
  • Reserve path p99 under 1 s; search can be slower and eventually consistent.
  • Scale: 5,000 hotels, 1M rooms — bookings land at roughly 3 TPS.
  • Bursty contention on hot rows: one property, one weekend, everyone booking at once.
  • Availability over latency on the read side; the booking transaction favours consistency.
Say this out loud

I'll say the uncomfortable thing first: at three bookings a second, one relational database handles this without noticing. So the interview isn't about scale — it's about modelling inventory so concurrency is cheap, and making retries safe. That's where I'll spend my time.

02

Back-of-envelope estimates

Two minutes of arithmetic whose whole purpose is to kill over-engineering: the funnel shows every layer is small, and the inventory table — the only interesting sizing — fits on one machine with room to spare.

QuantityEstimateHow you got there
Reservations/day~240,0001M rooms × 70% occupancy ÷ 3-night average stay. Sounds big; it isn't.
Booking writes~3/s240k ÷ 86,400 s. Any Postgres or MySQL instance absorbs this at idle.
Traffic funnel300 → 30 → 3 QPSRule of ~10× drop-off per step: view room → view booking page → place reservation. Sizes each tier honestly.
Inventory rows~73M5k hotels × ~20 room types × 730 days (a 2-year booking window). One row per room type per date — fits one MySQL instance easily.
Row size / total~100 B → ~7 GBhotel_id, room_type_id, date, two counters. The entire inventory table is smaller than one photo album.
Say this out loud

Seventy-three million rows and three writes a second — I get to say 'single ACID database, replicas for reads' with a straight face, and sharding by hotel_id stays in my back pocket as the future story, not the design.

03

Sketch the API

Standard CRUD except for one deliberate detail: the client generates the reservation id before submitting, which is the idempotency key that makes retries safe.

GET/v1/search?location=&checkin=&checkout=&guests=Hotels with an available room type for the whole date range; paginated, cache-friendly, eventually consistent.
GET/v1/hotels/{hotelId}Hotel detail, room types, nightly rates. Read-mostly; CDN and cache do the heavy lifting.
POST/v1/reservationsbody: { reservationId, hotelId, roomTypeId, startDate, endDate } → 201 or 409. reservationId is a client-generated idempotency key.
DELETE/v1/reservations/{id}Cancel; returns inventory to the pool inside the same transaction that flips the status.
  • The client mints reservationId (a UUID) when the booking page loads. A double-click, a timeout retry, or a flaky network resubmits the same id; a unique constraint makes the second insert a no-op that returns the original result. Idempotency designed in, not bolted on.
  • Price is re-quoted server-side at reserve time from the rates table — the client's displayed price is advisory. If it moved, return 409 with the new quote rather than silently charging a different amount.
04

Data model

The single most important decision in this question is one table: inventory as counters per (hotel, room type, date), not per physical room.

hotel / room_type_rate
hotel_id PK · name · location · … / hotel_id · room_type_id · date · price
Reference data plus nightly pricing; read-mostly, cached.
room_type_inventory
hotel_id · room_type_id · date (composite PK) · total_inventory · total_reserved
One row per room type per date — the key modelling move. Booking = increment total_reserved on every night in the range.
reservation
reservation_id PK · hotel_id · room_type_id · start_date · end_date · status · guest_id
Status is an enum (pending → paid → cancelled/refunded/rejected), never booleans. PK doubles as the idempotency key.
The database call

MySQL or Postgres, one primary — money-adjacent inventory wants ACID transactions, and 3 TPS doesn't begin to justify anything else. Read replicas serve search and hotel pages. When I'd switch: if inventory outgrew one box or one region, I'd shard by hotel_id — every query in the system carries it, so shards never talk to each other — but at 7 GB of inventory that's a slide about the future, not part of this design.

05

High-level design

A read side that's all cache and replicas, and a write side where one ACID transaction checks inventory, increments counters for every night, and inserts the reservation — atomically or not at all.

client edge service data async Guest API gateway auth, rate limit Search service Reservation service one ACID txn Hotel admin svc Redis inventory cache MySQL inventory, reservations Kafka CDC events PSP Stripe cond. update availability reserve invalidate CDC search set inventory on miss charge webhook
notice the cache serves search but the reservation service goes straight to mysql — 'cache says maybe, database says yes' is the consistency rule that lets everything upstream be stale.
  1. Search: guest queries by location and dates → search service reads cached availability from Redis, falling back to a MySQL replica. Staleness here is fine by design — the booking transaction re-checks.
  2. Reserve: guest submits with a client-generated reservationId → the reservation service opens one transaction: for every night in the range, UPDATE room_type_inventory SET total_reserved = total_reserved + 1 WHERE hotel_id=? AND room_type_id=? AND date=? AND total_reserved < total_inventory * 1.1, then inserts the reservation row as pending.
  3. If any night's update matches zero rows, the whole transaction rolls back and the guest sees "no longer available" — partial multi-night bookings can't exist.
  4. Pay: the service creates a payment with the PSP; the webhook flips the reservation pending → paid. A failed or expired payment triggers the compensating transaction: decrement the counters back, mark rejected.
  5. CDC streams inventory changes into Kafka to invalidate cached availability and feed the search index — the cache heals itself without dual writes.
06

Deep dives — where the interview is won

Phase 01

Book room types, not room numbers — the model that deletes the contention

The naive schema locks a specific physical room (room 204) per night. That invents contention that doesn't exist in the business: guests don't care which room they get, hotels assign numbers at check-in, and maintenance swaps rooms freely. Modelling it that way turns every popular property into hundreds of tiny lock fights and makes 'move the guest to an identical room' a data migration.

The right shape is a counter: room_type_inventory (hotel_id, room_type_id, date, total_inventory, total_reserved) — one row per room type per date. Booking three nights touches exactly three rows. Availability is one indexed range read. Contention collapses from per-room to per-(type, date), and the hotel's real-world flexibility is preserved because physical assignment happens at the desk, outside this system. This single modelling decision is worth more than any scaling discussion in this interview.

book type inv room # Booking type 'deluxe king' count -= 1 assign 214 at check-in reserve type later
room type is the countable unit. room number assigned at check-in, not at booking.
Trade-offYou give up 'pick your exact room' as a feature — if the product truly needs it (cruise cabins, specific villas), you're back to row-per-unit and you accept the per-unit contention it brings.
Phase 02

Concurrency in one conditional update, overbooking included

Two guests race for the last room. Three standard defences: pessimistic locking (SELECT ... FOR UPDATE on the inventory rows) — simple, correct, but serialises every booking on a hot date and invites deadlocks when two multi-night bookings lock date rows in different orders. Optimistic locking (version column, retry on conflict) — great when contention is low, which at 3 TPS it almost always is. The constraint approach: make the update itself conditional — SET total_reserved = total_reserved + 1 WHERE ... AND total_reserved < total_inventory * 1.1 — and let a zero row count mean 'sold out'.

I'd lead with the conditional update, optionally backed by a CHECK (total_reserved <= total_inventory 1.1) constraint so no code path can oversell even by accident. It's the least machinery: no lock manager, no retry loop, no version column — the database's own atomicity does the work. And notice where overbooking went: it's not a subsystem, it's the literal 1.1 in the predicate, tunable per hotel by storing the factor on the hotel row. Interviewers love seeing a scary-sounding requirement dissolve into one multiplication.

svc db Booking svc UPDATE ... WHERE available > 0 atomic 1 statement
single conditional UPDATE guards against overbook. no read + check gap.
Trade-offThe conditional update gives you no queueing fairness — under a genuine burst, whoever's transaction lands first wins and the rest fail fast; if the product wanted holds-while-you-pay, you'd add a TTL'd reservation state on top and accept the sweeper machinery.
Phase 03

Idempotency: the double-click must not book two rooms

The failure that happens in production: a guest clicks Book, the request succeeds server-side, the response times out, and the client retries — or the guest double-clicks. Without protection that's two reservations and two charges. The fix costs one design decision: the client generates reservationId when the booking form loads, and it's the primary key of the reservation table. The retry inserts the same key, violates the constraint, and the service returns the original reservation instead of creating anything.

The same discipline extends down the payment hop: the PSP call carries an idempotency key derived from the reservation id, so a retried charge is deduplicated on their side too. The general rule worth stating: exactly-once is always at-least-once plus idempotency — you can't prevent retries, so you make them harmless at every hop that has side effects.

user svc keys Double-click Booking API idempotency table same key ×2 check + cache
client's idempotency key stored server-side. second click sees prior result, returns it.
Trade-offTrusting a client-generated key means a buggy client that regenerates UUIDs per retry silently loses the protection — the server can add a fallback dedupe on (guest_id, hotel_id, dates, small time window), at the cost of occasionally blocking a legitimate identical booking.
Phase 04

Reservation plus payment without 2PC: keep one transaction, compensate the rest

The reserve flow spans my database and an external payment provider, and no transaction coordinator covers both — two-phase commit isn't on the menu and interviewers dock points for reaching for it. The pattern is a small saga: commit the inventory decrement and the pending reservation locally first, then attempt payment. Success flips the status to paid; failure or timeout runs the compensating transaction — decrement the counters back, mark the reservation rejected. Every state is explicit in the status enum, so a crash mid-saga leaves a row a reconciliation job can find and finish.

This is also my argument for keeping inventory and reservations in one database: the only cross-service hop left is the payment, which is unavoidably external. Splitting inventory and reservations into separate services with separate stores — microservice dogma — would manufacture a second distributed boundary and force saga machinery where a local ACID transaction was available for free.

flow psp Reserve inv Confirm booking Release inv Charge fail ok
reserve inv locally, then charge; on charge failure, release inv. no 2PC.
Trade-offSagas trade the clean all-or-nothing of a transaction for windows of intermediate state — a room can be held by a pending reservation whose payment will fail — so you accept a sweeper that expires stale pendings and returns their inventory.
07

Follow-ups you should expect

How do you make a 3-night booking atomic — what if night two is sold out?
All three inventory updates and the reservation insert run in one database transaction. Each night's update is conditional; if any matches zero rows the whole transaction rolls back, so a partial stay can never exist. This is precisely why I fought to keep inventory and reservations in the same ACID store.
The booking page showed a room, but it's gone by the time the guest books. Acceptable?
Yes, and by design — the cache and search index are allowed to be stale because the reserve transaction re-validates against the database. The rule I'd state is 'cache says maybe, database says yes'. The UX cost is an occasional 'no longer available' at the last step, which every real booking site shows; the alternative is holds on page-view, which wastes inventory on window-shoppers.
How would this change for Airbnb rather than hotels?
Each listing is a single unit, so inventory degenerates to one row per listing per night with total_inventory = 1 — effectively a per-night availability flag, and contention is per-listing, naturally low. The centre of gravity shifts to search: geo plus date-range availability across millions of listings, which pushes me to Elasticsearch with a geo index fed by CDC. The booking transaction itself barely changes.
When would you shard, and how?
When one primary can't hold the working set or a region needs local writes — nowhere near 7 GB and 3 TPS. I'd shard by hotel_id: search, booking, admin, every query carries it, so there are no cross-shard transactions, and a hot property is still confined to one shard. The follow-up trap is date-based sharding, which sprays one multi-night booking across shards — say why you rejected it.
A guest cancels — how does the room come back?
The cancel flips the reservation status and decrements total_reserved for each night in the same transaction, so inventory return is atomic with the state change. Refunds go through the PSP asynchronously with the same idempotency discipline as charges. CDC then invalidates cached availability, so the room reappears in search within seconds.

Where candidates lose the room

  • Modelling inventory as physical room numbers and locking room 204 per night — invented contention, messy data, and the interviewer knows hotels assign rooms at check-in.
  • Scaling theatre: sharding, queues and NoSQL for 3 TPS — the numbers were chosen small precisely to test whether you notice.
  • No idempotency key, so a timeout retry after payment books and charges twice — the most production-real failure in the whole question.
  • Reaching for 2PC or a distributed transaction across reservation and payment instead of a saga with compensating updates.
  • Treating overbooking as a scary subsystem instead of a one-line change to the update predicate — it signals you didn't own the inventory model.

Concepts this leans on

Found a bug or want more?

Tell me what's broken, missing, or wrong. Every message lands in my inbox.