00. Design a Hotel / Stay Booking System¶
~20 min read · Level: intermediate · Part 1 of 4 (Overview → HLD → LLD → Q&A)
Problem¶
A stay-booking system lets a guest search a huge inventory of places to stay, see which ones are free for their dates and price, and reserve one. This is the product behind Airbnb and Booking.com: someone types "Goa, Dec 24 to Dec 27, 2 guests," scrolls a map of results, opens a listing, sees a nightly price, and clicks Reserve. Behind that click, the system has to guarantee one thing above all others — that the room they just reserved is actually theirs, and that nobody else walks into the same room on the same night.
The search half of this looks like a normal read-heavy problem: millions of listings, geo and price filters, results in a few hundred milliseconds. The booking half is where it gets sharp. Inventory is finite and dated — a single room on a single night is a unit that exactly one guest can own. When demand spikes on a scarce, desirable listing, many guests converge on the same few nights at the same instant, and the system must pick exactly one winner per night without ever selling the same night twice.
Thread one scenario through the whole design to keep the reasoning concrete: listing LST-42, a beachfront studio in Goa, one room, during the New Year peak. At 20:00:00 on a Tuesday, Guest A submits a reservation for Dec 24–27 (nights of the 24th, 25th, 26th) and Guest B submits one for Dec 25–28 (nights of the 25th, 26th, 27th) — both within the same 200 ms window. Their requests overlap on the nights of the 25th and 26th. Exactly one of them can win those nights; the other must be told, cleanly and fast, that the room is gone. That single collision — two bookers, one room, overlapping dates, same second — is the test every decision below has to pass.
Functional requirements¶
- Search stays: given a location, date range, guest count, and filters (price, amenities, property type), return relevant, currently-bookable listings ranked for the user.
- View availability & price: for a listing, show which dates are free and the nightly price for a given date range and party size.
- Reserve: place a hold on a listing for specific dates, then confirm it after payment — the two-step that makes the booking real.
- Manage bookings: let a guest view, and a host see, confirmed reservations; support cancellation and the availability that frees up.
- Dynamic pricing: let price vary by date, demand, lead time, and length of stay, quoted at search/detail time and honored through checkout.
De-scoped for this round, and worth saying out loud so the interviewer hears a choice and not a gap: the full payments ledger and payout scheduling (we treat the payment gateway as an external call), messaging between guest and host, reviews and ratings, fraud scoring, and tax/regulatory computation. Each is real; none changes the shape of the search-and-reserve core.
Non-functional requirements¶
The dominant constraint is inventory consistency under contention on the booking path: the same room-night must never be sold twice, even when bookers collide within the same instant. Everything on the write path bends to that. The read path has a softer bar — search can be a little stale and still be useful — which is exactly the split that shapes the architecture.
- Consistency (booking): reserving a room-night is a serialization point. Two overlapping reservations for the same nights must resolve to one winner and one clean rejection, with no double-sell, ever. This is the hard requirement.
- Consistency (search): eventual is fine. A result showing a listing that just got booked is acceptable — it becomes a rejection at reserve time, not a corrupted booking. Index lag of a few seconds is tolerable.
- Latency: search should return in ~300–500 ms p95 so browsing feels live; a reserve call should confirm or reject in well under a second so the loser learns fast.
- Availability: search and browse target four nines. The reserve path may fail closed — if we cannot guarantee the write is safe, we reject rather than risk a double-sell, because a rejected guest is recoverable and a double-booked guest at a check-in desk is not.
- Durability: a confirmed booking must never be lost. It is money and a promise a guest will travel for.
Scale estimation¶
Assume an Airbnb-scale service: 5 million active listings, 100 million searches/day, and 1 million bookings/day — a read-to-write ratio of about 100:1, which is typical for browse-heavy marketplaces.
Searches work out to 100M / 86,400 s ≈ 1,160 searches/second on average. Apply a 10× peak factor for evening browsing and holiday planning and design the search path for ~12,000 searches/second at peak. Each search is a geo-bounded query with several filter predicates returning tens to a few hundred candidates, so this is the number the search tier is sized against.
Bookings are 1M / 86,400 s ≈ 12 writes/second on average — genuinely modest. Even a 20× holiday surge is only ~250 reservations/second globally, a load any single database handles without breaking a sweat. The catch, and the whole point of the study, is that this load is not spread evenly: during the New Year peak, dozens of guests converge on LST-42's three nights inside a few seconds. The booking problem is not global throughput; it is local contention on one scarce key. 250 writes/second is trivial; 40 people fighting over the same 3 nights is not.
For storage, the availability calendar dominates the row count: 5M listings × 730 days (a 24-month booking horizon) ≈ 3.65 billion room-night cells. At ~40 bytes per cell that is ~145 GB if stored per-night, and far less if stored as availability ranges — either way it fits comfortably on a sharded store. Listing metadata is 5M × ~5 KB ≈ 25 GB (photos live in blob storage and a CDN, not here). Bookings accrue at 1M/day × ~1 KB × 365 ≈ 365 GB/year, and the per-night booking rows at ~3M/day × 50 B × 365 ≈ 55 GB/year — all modest, multi-year-comfortable numbers.
The search index is the interesting store: 5M listings × ~2 KB of indexed attributes ≈ 10 GB, which fits in the memory of a small Elasticsearch cluster, so geo + facet queries stay in RAM. Bandwidth is unremarkable — a search response is tens of KB of JSON, so 12,000 × ~30 KB ≈ 360 MB/s outbound at peak, served comfortably from the search tier and edge.
API sketch¶
GET /api/v1/search?lat=&lng=&radius=&checkin=2026-12-24&checkout=2026-12-27
&guests=2&price_max=&amenities=wifi,ac&sort=relevance
200: { "results": [ { "listing_id":"LST-42", "price_total":..., "avail":"likely" }, ... ],
"next_cursor": "..." }
GET /api/v1/listings/{id}/availability?from=2026-12-01&to=2027-01-31
200: { "listing_id":"LST-42", "unavailable": ["2026-12-20", ...], "min_nights": 2 }
GET /api/v1/listings/{id}/quote?checkin=2026-12-24&checkout=2026-12-27&guests=2
200: { "nightly": [4200,5800,5800], "total": 15800, "quote_id":"q_9f", "quote_ttl_s": 600 }
POST /api/v1/bookings # step 1: place a hold
body: { "listing_id":"LST-42", "checkin":"2026-12-24", "checkout":"2026-12-27",
"guests":2, "quote_id":"q_9f", "idempotency_key":"..." }
201: { "booking_id":"BKG-88", "state":"HELD", "hold_expires_at": "...", "total":15800 }
409: { "error":"dates_unavailable", "conflicting_nights":["2026-12-25","2026-12-26"] }
POST /api/v1/bookings/{id}/confirm # step 2: after payment succeeds
body: { "payment_token":"...", "idempotency_key":"..." }
200: { "booking_id":"BKG-88", "state":"CONFIRMED" }
Solutioning¶
Start by splitting the system in two along the consistency line, because search and booking want opposite things. Search wants breadth, speed, and rich ranking over millions of listings, and it can tolerate a stale view of availability. Booking wants a narrow, authoritative, strongly-consistent decision on a handful of room-nights. Forcing both through one store makes each worse: a search index tuned for fast fuzzy geo queries is a poor place to serialize a reservation, and a transactional inventory store is a poor place to run ranked geo-facet search at 12k QPS. So the shape is a denormalized search index (Elasticsearch) for discovery, and a transactional inventory store (the calendar) as the single source of truth for what is actually bookable. The index is allowed to lag; the calendar is never wrong.
That split forces the first defining tradeoff — search freshness versus index cost. If the search index carried exact, live availability, every one of ~1M daily bookings would have to update the index, and hot listings would churn their documents constantly, driving reindex load and still racing the booking that just happened. The resolution is to index only a coarse availability signal ("has open nights in this window") and treat any staleness as a miss to be resolved later: a booked room showing up in search results is not a data-integrity bug, it's a cache miss you resolve at reserve time. Search over-returns plausible candidates; the calendar and the reserve call are the only places correctness is enforced. That keeps the index cheap and fresh-enough while making double-booking structurally impossible to leak through search.
The second and central tradeoff is how to prevent double-booking under contention. The instinct is to reach for locks — SELECT ... FOR UPDATE the listing, hold it, do the work. That works but couples correctness to a lock you must hold, and it tempts you into holding that lock across a slow external payment call, which serializes an entire hot listing behind one guest's card processor. The cleaner framing: double-booking is not a locking problem, it's a uniqueness problem. Model inventory as one row per room-night with a unique constraint on (listing_id, date), and let the database's constraint be the single arbiter of who owns a night. To book Dec 24–26, insert three night-rows in one transaction; if any night is already taken, the constraint rejects the whole transaction and that booker loses — atomically, with no application-level lock to reason about. When Guest A and Guest B collide on LST-42, whichever transaction commits first owns the nights of the 25th and 26th, and the other's insert fails the constraint and returns 409 in milliseconds. Exactly one winner, by construction.
That same insight resolves the third tension — holding inventory across a slow payment without freezing the listing. Split reserve into two steps: a hold that commits night-rows in state HELD with a short expiry (say 10 minutes), and a confirm that flips them to CONFIRMED once payment clears. Because the hold is a committed row guarded by the unique constraint — not an open transaction holding a lock — the payment gateway can take its slow seconds without blocking anyone; the constraint still guarantees no second hold on the same night. If the guest never pays, a reaper releases the expired hold and the nights return to inventory. Two smaller decisions round it out: dynamic pricing is quoted before the hold and frozen into the hold via a quote_id, so the guest pays what they were shown even if demand moves the price mid-checkout; and cross-channel inventory (the same room listed on multiple sites) is kept consistent by making this calendar the one system of record that every channel reserves against, rather than syncing copies and hoping. The result is a system whose read path is a stale-tolerant search index and whose write path is a two-phase, constraint-guarded reservation — the following files take each half down to components, schemas, and the exact moment A and B collide.