Chapter 7: Design a Hotel Reservation System
volume2 hotel-reservation booking transactions concurrency
Status: π© Interview ready
Difficulty: Hard
Time to complete: 50 min read + practice
Overview
A hotel reservation system lets users search for available hotels by location and date, reserve rooms, and manage bookings. Booking.com, Expedia, and Airbnb are the canonical examples.
Why this matters:
- Classic concurrency and double-booking problem (appears at Booking.com, Airbnb, Expedia interviews)
- Deep dive into database transactions, locking strategies, and idempotency
- Teaches how to handle inventory at scale (same patterns apply to ticketing, airline seats)
Problem Statement
Design a hotel reservation system that:
- Allows users to search available hotels by location and date range
- Reserves rooms without double-booking
- Supports cancellation
- Handles concurrent booking attempts (race conditions)
- Provides exactly-once reservation semantics (idempotency)
Step 1: Requirements & Scope (5 min)
Functional Requirements
Clarifying questions:
- How many hotels and rooms? β 5,000 hotels, 1 million rooms
- Search by? β Location, check-in/check-out dates, room type
- Double-booking prevention required? β Yes, absolutely no double-booking
- Cancellations allowed? β Yes
- Payment in scope? β Yes, pre-authorization flow
- Any overbooking allowed (like airlines)? β Support configurable overbooking
Scope:
- Search hotels by location + date
- Reserve a room (prevent double booking)
- Cancel reservations
- View reservation history
- Payment integration (pre-auth β capture)
Non-Functional Requirements
- No double-booking: Highest priority correctness requirement
- Read heavy: 10:1 read/write ratio
- Scale: 5,000 hotels, 1M rooms
- 70% occupancy: ~700K rooms occupied on average
- Peak booking: 10% of bookings occur within 3 days of check-in
- Idempotency: Exactly-once reservation semantics
- Low latency: Search < 100ms, booking < 500ms
- High availability: 99.99% uptime
Scale Estimation
Hotels: 5,000
Rooms: 1,000,000 (avg 200 rooms/hotel)
Occupancy: 70% β 700,000 active reservations at any time
Daily bookings: 1,000,000 Γ 70% / 365 β 1,918 bookings/day
Peak bookings: 10% in last 3 days = ~64 bookings/hour in peak
Reads/writes: 10:1 ratio β reads dominate β cache aggressively
Room availability queries (search):
- 5,000 hotels Γ 365 days Γ ~20 room types = 36.5M records in inventory
- Very cacheable (availability changes infrequently)
Step 2: High-Level Design (10 min)
Services
βββββββββββββββββββββββββββββββββββββ
β API Gateway β
β (Auth, Rate Limiting, Routing) β
βββββββββββββββ¬ββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββΌββββββββββββββββββββββββββββ
β β β
βΌ βΌ βΌ
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ
β Hotel Service β β Rate Service β βReservation Serviceβ
β β β β β β
β - CRUD hotels β β - Dynamic pricingβ β - Create booking β
β - CRUD rooms β β - Seasonal rates β β - Cancel booking β
β - Amenities β β - Room type ratesβ β - View bookings β
β - Photos β β - Discounts β β - Check inventory β
ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββ¬ββββββββββ
β
βΌ
ββββββββββββββββββββ
β Payment Service β
β β
β - Pre-auth β
β - Capture β
β - Refund β
ββββββββββββββββββββ
Data Models
Hotel data:
CREATE TABLE hotel (
hotel_id BIGINT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
address VARCHAR(500),
city VARCHAR(100),
country VARCHAR(100),
star_rating TINYINT, -- 1-5
description TEXT,
created_at TIMESTAMP
);
CREATE TABLE room_type (
room_type_id BIGINT PRIMARY KEY,
hotel_id BIGINT REFERENCES hotel(hotel_id),
name VARCHAR(100), -- "King Suite", "Standard Double"
description TEXT,
max_occupancy INT,
price_per_night DECIMAL(10,2)
);Availability/Inventory data:
-- Core inventory table: one row per hotel + room type + date
CREATE TABLE room_type_inventory (
id BIGINT PRIMARY KEY,
hotel_id BIGINT NOT NULL,
room_type_id BIGINT NOT NULL,
date DATE NOT NULL,
total_inventory INT NOT NULL, -- total rooms of this type
total_reserved INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_hotel_roomtype_date (hotel_id, room_type_id, date)
);Reservation data:
CREATE TABLE reservation (
id BIGINT PRIMARY KEY,
hotel_id BIGINT NOT NULL,
room_type_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status ENUM('pending', 'confirmed', 'cancelled', 'refunded'),
start_date DATE NOT NULL,
end_date DATE NOT NULL,
idempotency_key VARCHAR(64) UNIQUE NOT NULL, -- client-generated UUID
created_at TIMESTAMP,
updated_at TIMESTAMP
);API Design
Search hotels:
GET /v1/hotels/availability?city=NYC&check_in=2026-06-01&check_out=2026-06-05&guests=2
Response:
{
"hotels": [
{
"hotel_id": 101,
"name": "Grand Hotel NYC",
"star_rating": 4,
"available_room_types": [
{
"room_type_id": 201,
"name": "King Suite",
"available_rooms": 5,
"price_per_night": 299.00
}
]
}
]
}
Create reservation:
POST /v1/reservations
{
"hotel_id": 101,
"room_type_id": 201,
"user_id": 9999,
"start_date": "2026-06-01",
"end_date": "2026-06-05",
"idempotency_key": "550e8400-e29b-41d4-a716-446655440000" // UUID from client
}
Response 201 Created:
{
"reservation_id": 7654321,
"status": "confirmed",
"total_price": 1196.00
}
Response 409 Conflict (double-booking detected):
{
"error": "no_availability",
"message": "Room type no longer available for selected dates"
}
Cancel reservation:
DELETE /v1/reservations/{reservation_id}
Response 200:
{
"reservation_id": 7654321,
"status": "cancelled",
"refund_amount": 1196.00
}
Step 3: Deep Dive (25 min)
The Core Problem: Preventing Double Booking
This is the crux of the interview. Two users try to book the last available room at the same time.
Time: T0 - 1 room of type "King Suite" available on 2026-06-01
(total_inventory=3, total_reserved=2 β 1 available)
User A: Check availability β sees 1 room available
User B: Check availability β sees 1 room available
User A: Books β total_reserved = 3 β
User B: Books β total_reserved = 4 β DOUBLE BOOKED!
Three solutions to prevent double booking:
Option 1: Pessimistic Locking (SELECT FOR UPDATE)
How it works: Lock the row when reading, preventing others from reading or writing until transaction completes.
BEGIN TRANSACTION;
-- Lock the inventory rows for the date range
SELECT * FROM room_type_inventory
WHERE hotel_id = 101
AND room_type_id = 201
AND date BETWEEN '2026-06-01' AND '2026-06-04'
FOR UPDATE; -- <-- Lock acquired, no one else can read/modify
-- Check availability
-- If available_rooms > 0 for all dates, proceed
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1
WHERE hotel_id = 101
AND room_type_id = 201
AND date BETWEEN '2026-06-01' AND '2026-06-04';
INSERT INTO reservation (...) VALUES (...);
COMMIT;Pros:
- Correct: Guarantees no double-booking
- Simple to reason about
Cons:
- Deadlock risk: Two transactions lock rows in different order
- Low throughput: Popular rooms on popular dates become bottleneck
- Lock contention: Other requests queue up waiting for lock
When to use: Low concurrency scenarios, safety-critical operations
Option 2: Optimistic Locking (Version Number / CAS)
How it works: No lock on read. Add a version column. On update, check that version hasnβt changed since you read it. If version changed, someone else updated first β retry.
-- Schema change: add version column
ALTER TABLE room_type_inventory ADD COLUMN version INT DEFAULT 0;
-- Step 1: Read (no lock)
SELECT id, total_inventory, total_reserved, version
FROM room_type_inventory
WHERE hotel_id = 101
AND room_type_id = 201
AND date = '2026-06-01';
-- Returns: id=5001, total_inventory=3, total_reserved=2, version=7
-- Step 2: In application code, verify availability
available = total_inventory - total_reserved -- = 1 (OK!)
-- Step 3: Try to update with CAS (Compare-And-Swap)
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1,
version = version + 1
WHERE id = 5001
AND version = 7; -- <-- Only succeeds if version is still 7!
-- Check rows affected:
-- If rows_affected = 1: Success! We won the race.
-- If rows_affected = 0: Someone else updated first (version changed) β RETRYPros:
- No deadlocks (no locks held)
- High read throughput
- Works well when conflicts are rare
Cons:
- Retry logic adds complexity
- High conflict rate under heavy load β many retries β poor performance
- Not suitable for very hot inventory (e.g., last few rooms of a popular hotel)
When to use: Low-to-moderate concurrency, reads >> writes, conflicts are rare
Option 3: Database Constraint (UNIQUE + Conditional Update)
How it works: Use a SQL constraint to enforce the invariant directly at the DB level.
-- Constraint: reserved cannot exceed total
-- Use a conditional update and check it atomically
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1
WHERE hotel_id = 101
AND room_type_id = 201
AND date BETWEEN '2026-06-01' AND '2026-06-04'
AND total_reserved < total_inventory; -- <-- DB enforces this atomically
-- If rows_affected == expected_days: Success
-- If rows_affected < expected_days: At least one date is fully booked β RollbackPros:
- Atomic at DB level
- Simple: no version column, no application-level retry loop
- No deadlock risk
Cons:
- Less explicit than version-based locking
- Needs careful transaction handling to rollback partial updates
Recommended Approach: Optimistic Locking + DB Constraint
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Booking Flow β
β β
β 1. Client sends booking request with idempotency_key β
β 2. Check idempotency_key β if exists, return cached result β
β 3. Read inventory (no lock) for all nights β
β 4. If any night has 0 availability β return 409 fast β
β 5. BEGIN TRANSACTION β
β a. UPDATE inventory WHERE total_reserved < total_inventoryβ
β b. INSERT INTO reservation β
β c. If any UPDATE affected 0 rows β ROLLBACK, return 409 β
β d. COMMIT β
β 6. Trigger payment pre-authorization β
β 7. Cache result against idempotency_key β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Idempotency: Preventing Duplicate Bookings
Problem: Network timeout after DB commit β client retries, creating a second booking for same reservation.
Client β POST /reservations β [network timeout]
Client doesn't know if it succeeded
Client β POST /reservations (retry) β Creates duplicate booking!
Solution: Idempotency Key
- Client generates a UUID before sending the request:
idempotency_key = UUID() - Server stores
idempotency_keywith UNIQUE constraint inreservationtable - On retry, server sees key already exists β returns original response (no duplicate)
Request 1: POST /reservations {idempotency_key: "abc-123", ...}
β Creates reservation #7654321
β Stores idempotency_key in DB
Network timeout β client doesn't see response
Request 2: POST /reservations {idempotency_key: "abc-123", ...} (retry)
β Server: SELECT * FROM reservation WHERE idempotency_key = "abc-123"
β Found! Return same response as Request 1
β NO new reservation created
Implementation:
-- UNIQUE constraint prevents duplicate inserts at DB level
CREATE TABLE reservation (
...
idempotency_key VARCHAR(64) UNIQUE NOT NULL
);
-- If INSERT fails with unique constraint violation:
-- β Fetch and return existing reservationDatabase Schema (Complete)
ββββββββββββββββββββββββββββββββββββββββββββββββ
β hotel β
β hotel_id (PK) | name | city | star_rating β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β 1:N
ββββββββββββββββββββΌββββββββββββββββββββββββββββ
β room_type β
β room_type_id (PK) | hotel_id (FK) | name β
β max_occupancy | price_per_night β
βββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β 1:N
βββββββββββββΌβββββββββββββββββββββββββββββββββββ
β room_type_inventory β
β id (PK) | hotel_id | room_type_id | date β
β total_inventory | total_reserved | version β
β UNIQUE(hotel_id, room_type_id, date) β
ββββββββββββββββββββββββββββββββββββββββββββββββ
ββββββββββββββββββββββββββββββββββββββββββββββββ
β reservation β
β id (PK) | hotel_id | room_type_id | user_id β
β status | start_date | end_date β
β idempotency_key (UNIQUE) | created_at β
ββββββββββββββββββββββββββββββββββββββββββββββββ
Overbooking Strategy
Hotels (and airlines) intentionally overbook to account for expected cancellations.
total_inventory = 100 (physical rooms)
overbooking_buffer = 10% β allow up to 110 reservations
Booking allowed when:
total_reserved < total_inventory * (1 + overbooking_ratio)
In DB:
UPDATE room_type_inventory
SET total_reserved = total_reserved + 1
WHERE total_reserved < (total_inventory * 1.10);
Why it works: Historical data shows ~10% cancellation rate. Overbooking by 10% fills rooms that would otherwise sit empty. If everyone shows up, hotel compensates guests with upgrades or alternative accommodation.
Caching Strategy
Problem: Availability queries are read-heavy (10:1 ratio). Querying DB for every search is wasteful.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Cache Architecture β
β β
β Search Query β Redis Cache β Cache Hit β Return data β
β β β
β Cache Miss β
β β β
β Read from DB β Populate cache β
β β
β Cache Key: "availability:{hotel_id}:{room_type_id} β
β :{date}" β
β Cache TTL: 30 seconds (availability is time-sensitive)β
β β
β Write path: DB write β Invalidate Redis cache β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
What to cache:
- Room availability counts (read frequently, changes on booking)
- Hotel metadata (name, description, photos β changes rarely, long TTL)
What NOT to cache:
- Reservation details (user PII, security concern)
- Final availability check before booking (must go to DB for correctness)
Search Optimization with Elasticsearch
Problem: SQL LIKE queries for hotel search by name/amenity/description are slow at scale.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Two-Phase Search β
β β
β Phase 1: Elasticsearch (text search + geo search) β
β Query: city=NYC, amenities=pool, rating>=4 β
β Returns: List of matching hotel_ids β
β β
β Phase 2: Availability DB / Redis β
β Query: Check room_type_inventory for returned hotel_ids β
β Returns: Hotels with available rooms for requested dates β
β β
β Search DB β Booking DB (separate concerns, separate scale) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Elasticsearch index:
{
"hotel_id": 101,
"name": "Grand Hotel NYC",
"city": "New York",
"location": { "lat": 40.7128, "lon": -74.0060 },
"star_rating": 4,
"amenities": ["pool", "gym", "spa", "parking"],
"description": "Luxury hotel in midtown Manhattan"
}Payment Flow
Step 1: Pre-Authorization (at time of booking)
Client β Payment Service β Card Network
"Reserve $1196.00 on card"
Card network holds funds but does not charge yet
Returns: pre_auth_id
Step 2: Reservation Confirmation
Reservation created in DB with status = "confirmed"
pre_auth_id stored with reservation
Step 3: Capture (at check-in or D-1 day)
Payment Service β Card Network
"Capture $1196.00" (using pre_auth_id)
Card is charged
Step 4: On Cancellation
Payment Service β Card Network
"Void pre-auth" (if before capture) or "Refund" (if after)
Why pre-auth: Protects hotel from non-payment. Protects guest from being charged for cancelled bookings. Standard in travel industry.
Microservices Architecture (Final)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Clients β
β (Web, iOS, Android, Partner APIs) β
ββββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β API Gateway β
β Auth (JWT) | Rate Limiting | SSL Termination β
ββββββββ¬ββββββββββββββββββββ¬βββββββββββββββββββββββ¬ββββββββββββββββ
β β β
βΌ βΌ βΌ
ββββββββββββββββ ββββββββββββββββββββ βββββββββββββββββββββ
β Hotel Service β βReservation Serviceβ β Rate Service β
β β β β β β
β - Hotel CRUD β β - Book room β β - Dynamic pricing β
β - Room CRUD β β - Cancel booking β β - Seasonal rates β
β - Photos β β - View bookings β β - Discount rules β
ββββββββ¬ββββββββ ββββββββββ¬ββββββββββ βββββββββββ¬ββββββββββ
β β β
βΌ βΌ β
ββββββββββββββββ ββββββββββββββββββββ β
β Hotel DB β β Reservation DB β β
β (MySQL RDS) β β (MySQL RDS) β β
β β β β β
β hotel β β reservation β β
β room_type β β room_type_ β β
β β β inventory β β
ββββββββββββββββ ββββββββββ¬ββββββββββ β
β β
ββββββββΌβββββββ β
β Redis βββββββββββββββββββ
β (Cache) β
βββββββββββββββ
βββββββββββββββββββββββ
β Elasticsearch β
β (Hotel search, β
β geo search, β
β text search) β
βββββββββββββββββββββββ
βββββββββββββββββββββββ
β Payment Service β
β (Stripe/Braintree β
β integration) β
βββββββββββββββββββββββ
Design Summary β Full Booking Flow
1. User searches: GET /hotels/availability?city=NYC&check_in=...
ββ API Gateway β Hotel Service
ββ Elasticsearch: find hotels matching city/amenities
ββ Redis: check availability counts for matching hotels
ββ Return: list of hotels with available room types
2. User selects room and clicks "Book"
ββ Client generates idempotency_key = UUID()
ββ POST /reservations {hotel_id, room_type_id, dates, idempotency_key}
3. Reservation Service receives request
ββ Check idempotency_key in reservation table
ββ If exists β return existing result (deduplicate)
ββ If new β continue
4. Pre-authorization
ββ Reservation Service β Payment Service
ββ Payment Service β Card Network: pre-auth $X
ββ Returns: pre_auth_id
5. Atomic DB transaction
ββ BEGIN TRANSACTION
ββ UPDATE room_type_inventory
SET total_reserved = total_reserved + 1
WHERE hotel_id=X AND room_type_id=Y AND date IN (...)
AND total_reserved < total_inventory
ββ If affected_rows != expected_nights β ROLLBACK β 409 Conflict
ββ INSERT INTO reservation (status='confirmed', pre_auth_id)
ββ COMMIT
6. Invalidate Redis cache for affected inventory
7. Return 201 Created with reservation_id
8. At check-in (D-day): Payment Service captures pre-auth
9. On cancellation:
ββ UPDATE reservation SET status='cancelled'
ββ UPDATE room_type_inventory SET total_reserved = total_reserved - 1
ββ Payment Service: void/refund pre-auth
Interview Questions & Answers
Q: How do you prevent double-booking?
A: Use a database transaction with a conditional UPDATE: UPDATE room_type_inventory SET total_reserved = total_reserved + 1 WHERE total_reserved < total_inventory. The DB evaluates this atomically. If rows_affected == 0, no room was available and we rollback. Optionally add an optimistic lock version column for higher-read scenarios. Never rely on application-level read-then-write β always enforce the constraint in the DB.
Q: What is idempotency and why do you need it for reservations?
A: Idempotency means the same request produces the same result when repeated. For reservations, if a network timeout causes the client to retry, we could create duplicate bookings. The solution is an idempotency key (client-generated UUID) stored with UNIQUE constraint. On retry, the server detects the key already exists and returns the original response without creating a new reservation.
Q: Pessimistic vs Optimistic locking β which would you choose?
A: For hotel reservations, optimistic locking (or a DB constraint) is generally better because: (1) read-heavy workload means most operations donβt conflict, (2) pessimistic locking creates contention on popular rooms/dates, (3) deadlock risk with pessimistic locking across multiple date rows. Use pessimistic locking only if the system requires strict serialization and conflict rates are very high.
Q: How would you handle the search at scale (5,000 hotels)?
A: Two-phase approach: Phase 1 uses Elasticsearch for text/geo search, returning matching hotel IDs quickly. Phase 2 checks Redis for real-time availability counts for those hotel IDs. This separates the search concern (text/geo) from the availability concern (inventory counts). Redis is particularly effective here since availability for a given hotel+room+date is a small integer that fits in a few bytes and can be cached with a short TTL (30 seconds is acceptable).
Q: What happens if the payment service fails during booking?
A: We need a saga or two-phase approach. The pre-auth happens before the DB reservation insert. If pre-auth fails β return error, no DB write. If DB write fails after pre-auth β void the pre-auth. If the void also fails β mark reservation as pending_payment_void and a background job retries the void asynchronously. Use a dead letter queue for permanent payment failures so they can be manually reviewed.
Key Takeaways
- Double-booking prevention: Conditional UPDATE in a DB transaction (
WHERE total_reserved < total_inventory) is the cleanest solution β atomic at the DB level, no deadlocks - Idempotency key: Client-generated UUID with UNIQUE constraint prevents duplicate reservations from retries
- Optimistic locking (version column + CAS) outperforms pessimistic locking for read-heavy booking workloads
- Separate search from booking: Elasticsearch for hotel discovery, relational DB + Redis for inventory management
- Cache availability aggressively: 10:1 read/write ratio means Redis can absorb most availability checks; always verify in DB before final commit
- Payment pre-authorization: Reserve funds before confirming; avoid charging before the booking is confirmed in DB
- Overbooking is intentional: Hotels accept it as a business model; encode
total_inventory * overbooking_ratioas the cap, not rawtotal_inventory
Related Resources
- ch09-distributed-id-generator - Generating reservation IDs at scale
- key-patterns > Idempotency - Idempotency patterns across services
- distributed-system-components > Database Transactions - ACID, transactions, locking
- ch04-rate-limiter - Rate limiting booking API to prevent abuse
Last Updated: 2026-04-13
Status: Core concurrency + transaction design β must know for booking/ticketing system interviews!