Hotel Booking
Design a hotel reservation system like Booking.com or Airbnb: from a simple availability calendar to a system that handles concurrent bookings, double-booking prevention, and room-level inventory management at scale.
What is a hotel booking system?
A hotel booking system lets guests search available rooms by location and date range, hold a room during checkout, and confirm a paid reservation. The system looks straightforward until you consider that millions of users search simultaneously and the availability question ("is room 214 free from April 1st to April 5th?") requires querying across a date range without scanning 500 million reservation rows. This question tests date-range indexing, concurrent inventory management, and the read/write split between an eventually consistent search path and a strongly consistent booking path.
I'd tell any candidate to focus on two things in this interview: the availability data model and the concurrency control. Everything else is standard CRUD. Those two problems are where the interesting engineering lives.
Functional Requirements
Core Requirements
- Users can search for available rooms by location, check-in date, check-out date, and guest count.
- Users can reserve a room and complete payment.
- Hotel managers can configure room types, prices, and availability.
- The system prevents double-booking of the same room on the same dates.
Below the Line (out of scope)
- Dynamic pricing and revenue management
- Reviews and ratings
Dynamic pricing requires a pricing engine with write access to RoomType.base_price that responds to real-time demand signals: occupancy rate, events in the area, competitor pricing. To add it, I would build a background pricing service that subscribes to booking events via a Kafka topic and updates prices outside the synchronous booking path. It does not touch the concurrency primitives that make this system interesting, so it stays out of scope.
Reviews and ratings is a read-heavy, eventually consistent feature with no write conflict with the booking path. The integration point is a review_submitted event published after a completed stay. A separate reviews service stores ratings and aggregates scores asynchronously without touching the inventory or reservation models.
The hardest part in scope: Checking room availability for a date range without scanning every stored reservation, and ensuring two users cannot book the same physical room for overlapping dates. These two problems drive nearly every design decision in this article.
Non-Functional Requirements
Core Requirements
- Scale: 50M DAU; ~500M total reservations stored; ~10,000 availability searches per second at peak.
- Booking throughput: ~1,000 bookings per second at peak.
- Latency: Search results under 200ms p99; booking confirmation under 500ms p99.
- Consistency: Strong consistency for room inventory. A room must never be double-booked.
- Database choice: PostgreSQL for both Hotels DB and Bookings DB. The booking write requires an ACID transaction that atomically locks inventory rows, increments a counter, and inserts a reservation. NoSQL databases (DynamoDB, Cassandra) lack multi-row transactions with row-level locking, which would force a distributed lock service with added latency and failure modes. Elasticsearch handles the search path where horizontal scale and schema flexibility actually apply.
- Availability: 99.99% uptime for the booking path (roughly 52 minutes downtime per year); search can tolerate brief eventual consistency.
- Durability: Confirmed reservations must never be silently lost across server failures.
Below the Line
- Multi-region active-active replication (a single-region design with read replicas is sufficient)
- Real-time fraud scoring during checkout (a separate async pipeline beside the booking path)
Read/write ratio: Availability searches outnumber bookings by roughly 100:1. Ten thousand searches per second against one thousand bookings per second is the defining number for this system. Almost every architectural decision traces back to it: the search path needs aggressive caching and a read-optimized index; the booking path needs ACID transactions and row-level locking. These are opposite requirements that must be served by different storage systems. Combining them in one service or one database is the design mistake this article is built around avoiding.
Core Entities
- Hotel: A property with name, geo-coordinates, address, and star rating. One hotel has many room types.
- RoomType: A category within a hotel (Deluxe Queen, King Suite). Holds base price, max occupancy, and amenity list. One room type has many physical rooms.
- Room: A specific bookable unit linked to a room type. Room 214, Room 412. The unit that appears in a confirmed booking.
- Reservation: A temporary hold created when checkout begins. Expires automatically if payment does not complete within a configurable window (15 minutes by default).
- Booking: A confirmed, paid reservation. The permanent record of a completed stay. Links a specific Room, a User, the stay dates, total price, and a payment reference.
- User: A guest account with contact info, payment methods, and booking history.
Full schema details, including the date-range index strategy and the room_type_inventory table design, come up in the deep dives. These six entities are enough to drive the API and High-Level Design sections without getting lost in column types.
API Design
FR 1: Search for available rooms:
# Returns available hotels matching the search criteria; served from the search index
GET /v1/hotels/search
Query: location, check_in_date, check_out_date, guests, page_cursor?
Response: {
results: [
{ hotel_id, name, rating, address, available_room_types: [...], lowest_price },
...
],
next_cursor: "eyJpZCI6Mj..."
}
GET over POST because this is a read with filter parameters. Cursor-based pagination handles open-ended result sets without OFFSET performance degradation at depth. The available_room_types field is pre-computed from the availability index, so this endpoint never scans the reservations table on the hot path.
FR 1b: View room type availability detail:
# Detailed availability for a specific room type and date range
GET /v1/hotels/{hotel_id}/room-types/{room_type_id}/availability
Query: check_in_date, check_out_date
Response: { room_type_id, available_count, price_per_night, total_price }
This endpoint drives the "how many rooms of this type are still open for my dates?" panel on the hotel detail page. The available_count is computed from a dedicated inventory table (see Deep Dive 1) rather than by joining against the full reservations history.
FR 2: Create a reservation (start checkout):
# Atomically holds one room of the requested type; returns reservation with expiry
POST /v1/reservations
Body: { room_type_id, check_in_date, check_out_date, user_id, idempotency_key }
Response: {
reservation_id: "res_abc123",
room_id: "room_214",
expires_at: "2026-03-29T12:15:00Z",
total_price: 450
}
idempotency_key is client-generated (a UUID v4) and required on every call. If a network timeout causes the client to retry, the server returns the original reservation instead of creating a duplicate. The server assigns a specific room_id from the available pool of the requested room_type_id. Two concurrent requests for the same room_type_id on the same dates must each receive a different room_id or one must receive 409 Conflict.
FR 2b: Confirm booking (complete checkout):
# Processes payment and converts the reservation to a confirmed booking
POST /v1/reservations/{reservation_id}/confirm
Body: { payment_method_id: "pm_abc" }
Response: { booking_id: "bk_def", room_id, check_in_date, check_out_date, total_amount }
The server re-validates that the reservation has not expired before charging. If the reservation is expired, the endpoint returns 410 Gone and the client must restart the checkout flow from the search step.
FR 2c: Cancel a reservation (explicit checkout abandon):
# Explicit release; expired reservations are also released by the background Expiry Worker
DELETE /v1/reservations/{reservation_id}
Response: 204 No Content
The client sends this on explicit cancel. The Expiry Worker handles silently abandoned reservations automatically. Both paths converge on the same state change: room status returns to available.
FR 3: Hotel manager: configure room types and inventory:
# Create a new room type; request triggers creation of N individual Room rows
POST /v1/hotels/{hotel_id}/room-types
Body: { name, max_occupancy, base_price, amenities, room_count }
Response: { room_type_id, rooms_created: 12 }
# Update pricing or mark a room type as inactive for maintenance
PATCH /v1/hotels/{hotel_id}/room-types/{room_type_id}
Body: { base_price?, is_active? }
Response: { room_type_id, updated_fields: [...] }
PATCH over PUT because managers rarely update all fields at once. room_count in the POST body causes the server to generate that many Room rows in the same transaction, keeping the room type and its physical rooms atomically consistent.
High-Level Design
1. Hotel search
The search path: a user submits location and date filters; the system returns a paginated list of hotels with available room types and lowest price.
The naive approach is a SQL query against the reservations table: find all hotels near the location, for each hotel find which rooms are booked in the date range, subtract from total, return what remains. At 50M DAU and 10,000 searches per second this is a full table scan on a 500M-row table for every request. It never works.
I always start by showing this naive approach on the whiteboard and letting the interviewer see why it fails. Jumping straight to Elasticsearch makes you look like you memorized the answer.
I always split search from booking before drawing any other boxes on the whiteboard, because keeping them in one service forces you to optimize for opposite workloads simultaneously. Search is read-heavy, geospatial, and tolerant of a few seconds of staleness. Booking is write-bound, transactional, and must be immediately consistent.
Components:
- Client: Web or mobile app sending search queries via the API Gateway.
- API Gateway: Routes
/v1/hotels/searchtraffic to the Search Service; handles auth token validation and rate limiting. - Search Service: Stateless service that applies geo and availability filters against Elasticsearch and returns paginated results.
- Elasticsearch (Search Index): Hotel documents with geo-coordinates indexed for bounding-box queries and pre-aggregated availability counts per date range. Updated asynchronously when bookings are created or cancelled.
- Hotels DB (PostgreSQL): Source of truth for hotel metadata, room types, and the inventory counter table. Elasticsearch is populated from here via a change event pipeline.
Request walkthrough:
- Client sends
GET /v1/hotels/search?location=NYC&check_in_date=2026-04-01&check_out_date=2026-04-03&guests=2. - API Gateway authenticates the request and routes to the Search Service.
- Search Service queries Elasticsearch: geo-filter by bounding box around the requested location, filter
available_count[2026-04-01] > 0ANDavailable_count[2026-04-02] > 0for all nights in the range, sort by rating or price. - Elasticsearch returns matching hotel documents with pre-aggregated availability per room type.
- Search Service returns paginated results with a cursor for the next page. No PostgreSQL reads on this path.
The search path never touches the reservations table directly. Availability counts in Elasticsearch are maintained by a background pipeline that processes booking events. The booking path comes next.
2. Room reservation and payment
The booking path: a user selects a room type; the system assigns a specific room, creates a reservation with an expiry, takes payment, and confirms the booking.
Adding a dedicated Booking Service keeps booking logic isolated from search. Both services scale independently: search is read-heavy (100:1) while booking is write-bound with ACID requirements. Merging them means a surge in search traffic degrades booking write latency, which is the opposite of what you want.
Components (added):
- Booking Service: Orchestrates reservation creation, room assignment (with concurrency control), and payment confirmation. The concurrency mechanism is treated as a black box here and covered in detail in Deep Dive 2.
- Payment Service: Wraps the external payment processor (Stripe). Returns a
payment_intent_idfor durability. - Bookings DB (PostgreSQL): Stores
reservationsandbookingstables separately from hotel metadata. Handles the ACID transaction for the booking write and row-level locks. - Expiry Worker: Background job that runs every 30 seconds and releases expired reservation holds back to available inventory.
- Redis: Stores idempotency key responses and room hold pre-filter keys. Non-authoritative; the database is always the source of truth.
Request walkthrough (create reservation):
- User selects a room type and clicks "Reserve". Client sends
POST /v1/reservationswith an idempotency key. - Booking Service checks the idempotency key in Redis. If found, returns the cached response immediately without a DB round-trip.
- Booking Service selects one available
room_idof the requested type for the date range, applying a row-level lock to prevent concurrent assignment. - Booking Service inserts a
Reservationrow withexpires_at = NOW() + 15 minutesand decrements the available inventory counter. - Returns
reservation_id,room_id,expires_at, and total price to the client.
Request walkthrough (confirm booking):
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.