“Design Airbnb” sounds like a listings app: a host posts a room, a guest searches a city, they book, money changes hands. The interviewer lets that run for a minute, then asks the two questions the toy version cannot answer. First: a guest in Bangalore types “Goa, 24-27 December, 2 guests” and expects, in under a second, the handful of places that are actually free for those exact three nights out of millions of listings. Second: two guests, on two continents, both tap “book” on the same beach villa for overlapping dates at the same instant. Exactly one of them can win, and the loser must never receive a confirmation for a villa that is already taken.
Those two problems - searching a huge inventory by availability over a date range, and reserving a date range under concurrency without double-booking - are the spine of the system. Everything hard hangs off them: how you index availability so a date-range query is fast, how you represent a booking as an interval and detect when two intervals overlap, how you claim those nights atomically so two overlapping reservations resolve to exactly one winner, and how you price a stay when the nightly rate changes by season, demand, and length of stay. Let me build it properly.
Functional Requirements (FR)
In scope:
- Search listings by location, dates, and guests. A guest searches a city or map area for a date range and party size, and gets listings that are available for every night in that range, ranked, with prices. This is the read-heavy heart of the product.
- View a listing. Photos, amenities, house rules, reviews, a calendar of which dates are open, and the total price for the selected stay.
- Reserve / book. The guest picks exact dates and books. The system atomically claims those nights so no one else can book an overlapping range, takes payment, and confirms.
- Guarantee no double-booking. For a given listing, no two confirmed reservations overlap on any night. Ever. This is the correctness heart.
- Host calendar management. A host blocks dates, sets availability, sets nightly base price and rules (min-stay, weekend pricing).
- Pricing. Compute the total price of a stay: base nightly rate adjusted by season, demand, day-of-week, length-of-stay discounts, plus cleaning fee and taxes.
- Cancel / refund (basic): a reservation can be cancelled per policy, freeing the dates.
Explicitly out of scope (say it so you own the scope):
- Payment gateway internals. Card networks, payouts to hosts, escrow ledgers, currency conversion. We integrate a payment provider as a service call and design around its async, sometimes-failing nature. We do not build the gateway.
- Messaging, reviews/ratings ML, fraud, identity verification, photos pipeline. Each is its own system; we leave clean seams.
- Ranking/relevance ML. We assume a ranking service scores candidate listings; we design the retrieval of available candidates and hand them off.
- Host onboarding / listing ingestion. Assume listings, calendars, and geo already exist.
The one decision that drives everything: this is not a strict finite-inventory flash-sale problem like a concert on-sale - it is a date-range inventory problem at massive read scale. A listing is not “one seat”; it is a calendar of nights, and both search and booking operate on ranges of nights, not points. The defining constraints are (a) answering “free for these N nights?” fast across millions of listings, and (b) claiming a contiguous range atomically. Overlap of intervals, not raw contention, is the recurring theme.
Non-Functional Requirements (NFR)
- Scale: ~150M users, ~6M active listings worldwide, ~2M nights booked per day. Search is the dominant load: hundreds of millions of searches per day, each scanning a geographic area. Booking volume is modest in aggregate but concentrated on desirable listings in peak season.
- Latency: search results under ~500ms end to end (retrieval + availability filter + price + rank). Listing/calendar read under ~200ms. A reservation attempt (the contended write) must return a clear yes/no in under ~500ms - the guest cannot watch a spinner while someone else takes the dates. Checkout including payment under a couple of seconds.
- Availability: 99.99% on search and listing reads. The booking path must be highly available but may fail closed: if we are unsure whether a range is free, we refuse the booking. Refusing a booking is acceptable; double-booking is not.
- Consistency: the reservation operation is strongly consistent - linearizable per (listing, date range). A night transitions open -> held -> booked at most once. Search results, calendars shown while browsing, and “X left at this price” can be eventually consistent and cached; the authoritative check happens atomically at reserve time.
- Durability: confirmed reservations and payments are financial records - durable, replicated, never lost. Holds are ephemeral by design and may be lost on a crash (a lost hold just frees the dates early, which is safe).
- Correctness invariant (the whole point): for any listing, the set of confirmed reservations has no two intervals that overlap on any night. No double-booking under any interleaving.
Back-of-the-Envelope Estimation (BoE)
Real numbers with the arithmetic, because they dictate where the pressure is.
Search (the dominant, high-volume path):
Say 100M active users, 30% search on a given day, ~5 searches each session
= 100M * 0.30 * 5 = 150M searches/day
= 150,000,000 / 86,400
≈ 1,740 searches/sec average
Peak (holiday booking surge, ~8x) ≈ 14,000 searches/sec
Every search scans a geographic area, filters candidate listings by availability over a date range, prices them, and ranks. Search is where the CPU and index pressure live.
Listing/calendar reads:
Each search that leads somewhere -> ~10 listing detail views.
Roughly 150M searches -> ~200M listing/calendar reads/day
= 200,000,000 / 86,400
≈ 2,300 reads/sec average, ~18,000/sec peak.
Cacheable, replicable.
Bookings (writes - low volume in aggregate):
2M nights booked/day. Average stay ~3 nights -> ~670k reservations/day.
= 670,000 / 86,400
≈ 8 reservations/sec average
Peak, spread across all listings ≈ a couple hundred/sec
8 reservations/sec is nothing. If aggregate write rate were the problem, a single database would do. It is not the problem. The problem is the shape: a desirable listing in Goa over Christmas has dozens of guests eyeing the same three nights, so contention concentrates on a small set of (listing, date) cells even though global write throughput is tiny.
Availability storage:
6M listings, each with a calendar ~2 years out = 730 nights.
Represent availability as intervals, not one row per night, but bound the worst case:
6M * 730 = ~4.4B (listing, night) cells if fully materialized.
Per cell ≈ 20 bytes (status, price hint) -> ~88 GB fully materialized.
As intervals (a listing has a handful of booked/blocked ranges) it is far smaller:
avg ~20 intervals/listing * 6M * ~60 bytes ≈ 7 GB.
Reservations: ~670k/day * 500 bytes = ~335 MB/day ≈ 122 GB/year.
Storage is modest. This is not a big-data problem; it is a search-and-concurrency problem.
Search index size:
6M listings * ~2 KB indexed fields (geo, amenities, price, capacity) ≈ 12 GB.
Availability cannot live only in the text index (it changes constantly and is a
range predicate), so the index holds static/slow fields and we filter availability
against a fast availability store as a second stage. More on this below.
Cache:
Hot listings + their calendars: a few hundred thousand popular listings * ~5 KB
≈ 1-2 GB of hot calendars/detail in Redis. Search result caching for popular
(city, date, guests) tuples: tens of thousands of hot tuples * a few KB ≈ hundreds of MB.
The takeaways: search is high-volume and genuinely hard because availability is a date-range predicate over millions of listings; booking is low-volume but contended on hot (listing, date) cells and must be atomic over an interval. The design budget goes into a two-stage search (cheap candidate retrieval, then availability filter and pricing) and into making the interval reservation atomic and overlap-free.
High-Level Design (HLD)
The architecture separates the read/search plane (retrieve candidate listings, filter by availability, price, rank - cached, replicated, eventually consistent) from the inventory plane (the strongly-consistent reservation state machine where date ranges are held and booked). A booking flows: search (fast, cached, approximate) -> open a specific date range on one listing -> hold the range (strong, contended) -> pay -> confirm the hold into a reservation (strong, durable).
┌──────────────┐ search / view / book ┌───────────────────┐
│ Guest (web/ │───────────────────────────────────▶│ Payment Provider │
│ mobile app) │ │ (external, async)│
└──────┬────────┘ └─────────▲─────────┘
│ │ webhook
┌──────▼───────────────┐ │
│ CDN / Edge │ static assets, cached search pages │
└──────┬───────────────┘ │
│ │
┌──────▼───────────────┐ │
│ API Gateway │ auth, rate limit, routing │
└──────┬───────────────┘ │
│ │
┌──────┼───────────────────────────────────────────────────────┼──────┐
│ │ READ / SEARCH PLANE │ │
│ ┌────▼─────────┐ ┌──────────────┐ ┌────────────┐ ┌─────────▼───┐ │
│ │ Search Svc │ │ Availability │ │ Pricing Svc │ │ Listing Svc │ │
│ │ (geo + filter│─▶│ Filter (range│ │ (nightly → │ │ (details, │ │
│ │ candidates) │ │ overlap chk)│ │ stay total)│ │ calendar) │ │
│ └──────┬───────┘ └──────┬───────┘ └─────────────┘ └──────┬──────┘ │
│ │ │ │ │
│ ┌──────▼───────┐ ┌──────▼────────┐ ┌──────────▼──────┐ │
│ │ Geo/Text │ │ Availability │ │ Redis cache │ │
│ │ index (ES / │ │ read replica /│ │ (hot calendars, │ │
│ │ geohash) │ │ cache │ │ search results)│ │
│ └───────────────┘ └───────────────┘ └─────────────────┘ │
└──────────────────────────┬──────────────────────────────────────────┘
│ reserve a specific range
┌──────────────────────────┼──────────────────────────────────────────┐
│ INVENTORY PLANE (strong) │
│ ┌───────────────────────▼──────────────────────────────────────┐ │
│ │ Reservation / Inventory Service │ │
│ │ HOLD (atomic claim of a date range, TTL) │ │
│ │ CONFIRM (hold → booked on payment success) │ │
│ │ RELEASE (TTL expiry / cancel / pay fail) │ │
│ └───────┬───────────────────────────────────┬───────────────────┘ │
│ │ │ │
│ ┌───────▼────────┐ ┌───────▼───────────────────┐ │
│ │ Hold store │ │ Inventory DB (SQL, │ │
│ │ (Redis, TTL, │ │ reservations as intervals,│ │
│ │ atomic Lua) │ │ sharded by listing_id) │ │
│ └────────────────┘ └───────────────────────────┘ │
└───────────────────────────────────────────┬──────────────────────────┘
│ │ CDC / events
┌──────▼───────────┐ ┌──────────────────┐ │ ┌──────────────────────┐
│ Reaper (sweeps │ │ Notification Svc │ └─▶│ Availability updater │
│ expired holds) │ │ (confirmations) │ │ (push booked ranges → │
└───────────────────┘ └──────────────────┘ │ search/avail indexes)│
└──────────────────────┘
Search flow (the dominant, hard read path):
- Guest searches “Goa, 24-27 Dec, 2 guests.” The Search Service does cheap candidate retrieval from a geo/text index: listings in the map area matching capacity, amenities, price band. This ignores availability (it is a range predicate the text index handles poorly) and returns, say, a few thousand candidates.
- The Availability Filter takes those candidate ids and keeps only listings with no overlapping booked/blocked interval across every night 24 -> 27. This is a range-overlap check against the availability store, done in bulk for the candidate set.
- The Pricing Service computes each surviving listing’s total for the exact stay (nightly base adjusted for season/demand/day-of-week, length-of-stay discount, fees, taxes).
- A ranking step orders them; results are returned and the popular (area, dates, guests) tuple result may be cached briefly.
Reserve -> pay -> book flow (the hard write path):
- Guest picks a listing and exact dates [24 Dec -> 27 Dec] and requests a hold. The request hits the Reservation/Inventory Service.
- The service atomically claims that date range for this guest, checking no existing hold or booking overlaps it, and writes a hold with a TTL (say 15 minutes). Atomic means: the overlap check and the claim are one operation, so two overlapping requests cannot both win. If any night conflicts, the hold fails and the guest is told the dates just went.
- On a successful hold, the guest proceeds to payment; the calendar shows those nights as pending to others.
- Guest pays via the external Payment Provider (async - seconds, may fail). The hold TTL ticks.
- On payment success (webhook), the service confirms: in one strongly-consistent transaction it re-verifies no overlap, flips the range held -> booked in the durable Inventory DB, and writes the reservation record. A confirmation is sent; a CDC event pushes the newly-booked range to the search/availability indexes so future searches see it as taken.
- On payment failure, abandonment, or TTL expiry, the hold is released and the nights return to open. A background Reaper guarantees release even if nothing else fires.
The key insight: search is a two-stage funnel (cheap approximate retrieval, then exact availability filter and pricing), and booking is a strongly-consistent atomic claim over an interval promoted to a durable transaction. You serve millions of searches cheaply and approximately, and pay the cost of strong consistency only on the small, contended reservation operation.
Component Deep Dive
The hard parts, naive-first then evolved: (1) representing availability and detecting overlapping date ranges, (2) availability search over millions of listings, (3) the reservation concurrency problem (claiming a range without double-booking), (4) pricing a stay.
1. Representing availability and detecting overlap
Everything depends on how you model “is this listing free for these nights?” Get the representation wrong and both search and booking get slow and buggy.
Naive approach: one row per listing per night.
Table availability_nights( listing_id, night DATE, status ) -- open/booked/blocked
Booking Dec 24-27 = INSERT 3 rows (24,25,26). (checkout day 27 is not a night)
"Is it free 24-27?" = SELECT ... WHERE listing_id=? AND night IN (24,25,26) AND status!='open'
Where it breaks:
- Row explosion. 6M listings * 730 nights = ~4.4B rows, most of them “open” and therefore pure noise. You store and index billions of rows to represent mostly-empty calendars.
- Range queries are IN-lists of dates. A 14-night stay is a 14-element predicate; a flexible “any 3 nights in December” search becomes brutal. It works but scales poorly and the index is enormous.
- Still needs a lock per night to book atomically - manageable but clumsy over many rows.
It is simple and, for a single hotel with a bounded calendar, perfectly fine. It falls over at Airbnb’s listing count.
First evolution: store availability as intervals, not points. A listing’s calendar is a small set of unavailable intervals (booked or host-blocked); everything else is open by default. A booking is [start, end) - half-open, so checkout day is not a booked night and back-to-back stays (checkout 27th, check-in 27th) do not falsely conflict.
Table reservations(
listing_id, start_date, end_date, -- half-open [start_date, end_date)
status, ... )
Overlap of two half-open intervals [aS,aE) and [bS,bE) is the classic test:
overlap ⇔ aS < bE AND bS < aE
To ask “is listing L free for [24,27)?” you check that no existing reservation/block for L satisfies start_date < 27 AND end_date > 24. If none does, it is free. This is compact (a handful of intervals per listing, not 730 rows) and the overlap predicate is two comparisons.
But interval-scan-per-listing does not scale to search, where you must apply that predicate to thousands of candidate listings quickly. Two refinements make it fast:
Index the intervals for range queries. In PostgreSQL, a
daterangecolumn with a GiST index and the&&(overlaps) operator answers “does any interval overlap [24,27)?” in log time, and an exclusion constraint can even enforce non-overlap at the database level (below). In a general store, a segment/interval tree per listing, or bucketing by month, does the same.Keep a denormalized per-night bitmap for hot lookups. For search’s bulk availability filter, an interval scan across thousands of candidates is still work. So maintain, in the availability store (Redis or a columnar cache), a compact bitmap per listing: one bit per night for the next ~12-18 months, 1 = booked/blocked. A 540-night horizon is ~68 bytes per listing. “Free for [24,27)?” becomes: mask the 3 bits for those nights and test they are all zero - a few CPU instructions. Checking thousands of candidates is then a tight loop over tiny bitmaps, not thousands of DB range scans.
So we keep two representations, each for its job: intervals in SQL as the durable, overlap-enforced source of truth for booking; a per-listing bitmap (or daterange GiST) in the availability store as the fast filter for search. The bitmap is derived from the intervals and updated via CDC when a reservation is confirmed. The invariant: overlap is defined once (aS < bE AND bS < aE), enforced durably in SQL, and answered cheaply in the bitmap for reads.
2. Availability search over millions of listings
A guest searches an area for a date range. We must return available, priced, ranked listings in under ~500ms out of 6M.
Naive approach: query the database with all filters at once.
SELECT * FROM listings l
WHERE ST_DWithin(l.geo, :point, :radius)
AND l.capacity >= :guests AND :amenities ⊆ l.amenities
AND NOT EXISTS (SELECT 1 FROM reservations r
WHERE r.listing_id=l.id AND r.range && :requested_range)
ORDER BY rank LIMIT 50;
Where it breaks:
- One query doing geo + attribute + availability + rank. The availability
NOT EXISTScorrelated subquery runs per candidate; the geo filter, amenity filter, and availability filter fight over which index the planner uses. At 6M listings and thousands of candidates per area, this is slow and unpredictable. - Availability changes constantly and is a range predicate - keeping it inside the same index as slow-changing text/geo fields means either a stale index or constant reindexing churn.
- No caching leverage - every search is a full compound query.
The answer: a two-stage funnel - cheap candidate retrieval, then availability filter and pricing.
Stage 1 - candidate retrieval (approximate, availability-agnostic). A search index (Elasticsearch / a geo-sharded inverted index) holds the slow-changing fields: geo (indexed by geohash or an R-tree / S2 cells), capacity, amenities, price band, listing quality signals. A search retrieves candidates in the map area matching capacity/amenities/price - a few thousand ids - fast, and ignoring availability. Geohash prefix or S2-cell buckets make “listings in this map viewport” a bounded lookup, and the index shards by geo region so a city search hits a few shards, not all.
Stage 2 - availability filter (exact, fast). Take the candidate ids and apply the date-range overlap test using the per-listing bitmap from Component 1: for each candidate, test that bits for [start,end) are all zero. Thousands of bitmap tests take single-digit milliseconds. Only listings that pass survive. Because availability lives in its own fast store keyed by listing_id, it updates independently of the heavy text index (CDC pushes booked ranges into the bitmap in near-real-time), so the two never fight.
Stage 3 - pricing and ranking. Price each survivor for the exact stay (Component 4), then rank. Return the top N.
Why split it: the fields have opposite change rates and query shapes. Geo/amenities are slow-changing and suit an inverted/geo index; availability is fast-changing and is a range predicate that suits a bitmap/interval store; pricing is a computation over the exact dates. Bolting all three into one query means the fastest-changing dimension (availability) drags down the whole index. Splitting lets each scale and update on its own clock, and lets us cache Stage-1 candidates for popular areas.
Freshness vs correctness. Search availability is eventually consistent - a listing might show as free and get booked a second later. That is acceptable: the authoritative overlap check happens atomically at reserve time (Component 3). Search’s job is to be approximately right and fast; the reservation path is where correctness is absolute. We would rather occasionally show a just-taken listing (the guest re-searches) than make search slow to be perfectly current.
Flexible-date and map searches (“cheapest weekend in December,” “anywhere in Goa”) are handled by widening Stage 1 (more geo cells, more date windows) and testing multiple date ranges against the same bitmaps - the bitmap makes many-range tests cheap.
3. The reservation concurrency problem - claiming a range without double-booking
This is the correctness core. Two guests, overlapping dates, one listing: exactly one wins, and no two confirmed reservations ever overlap.
Naive approach: read the calendar, check it is free, then insert.
conflicts = SELECT 1 FROM reservations
WHERE listing_id=? AND range && '[24,27)'
if none:
INSERT INTO reservations(listing_id, range, ...) VALUES (?, '[24,27)', ...)
Where it breaks:
- The classic check-then-act race. Two requests both read “no conflict” for [24,27), both
INSERT. Now two reservations overlap - a double-booking. The gap between the read and the write is exactly where the second request slips in. Under peak-season contention on a hot listing this fires regularly, not rarely. - Partial-range problems. Guest A wants [24,27), guest B wants [26,29). They overlap only on the 26th. A naive per-night lock might grab some nights and not others, leaving a partial hold.
First evolution: let the database enforce non-overlap. Fold the check into the write so the conflict test and the insert are one atomic, isolated operation. PostgreSQL can do this directly with an exclusion constraint over a daterange:
ALTER TABLE reservations
ADD CONSTRAINT no_overlap
EXCLUDE USING gist (listing_id WITH =, during WITH &&)
WHERE (status IN ('held','booked'));
-- Two overlapping active reservations for the same listing cannot both exist.
-- The second INSERT fails with a constraint violation; the app treats it as "dates taken."
The exclusion constraint means the database itself guarantees no two active (held/booked) reservations for the same listing overlap, using the same && overlap operator, enforced under row/index locking. Two concurrent inserts for overlapping ranges: one commits, the other hits the constraint and rolls back. This single constraint eliminates the double-booking race at the source of truth - correctness does not depend on application code getting the check-then-act ordering right.
If the store is not PostgreSQL, achieve the same with a transaction that SELECT ... FOR UPDATEs the listing’s calendar row (or a per-listing lock row) so overlap-checking is serialized per listing, then insert. Either way the principle is: the overlap check and the claim must be a single atomic, isolated operation, serialized per listing.
Second evolution: hold before you book, with a TTL. A guest does not pay instantly; they spend a minute or two at checkout. During that window the range must be theirs and no one else’s, but not forever. So the first claim writes a hold: a row with status='held' and hold_expires_at, covered by the same exclusion constraint so a held range blocks overlapping holds and bookings alike.
For hot listings where many guests eye the same peak-season week, front the hold with an atomic in-memory claim in Redis to absorb contention before it reaches the DB. Because a range is not a single key, the Redis claim uses a Lua script (Redis runs it atomically, single-threaded) that checks a per-listing sorted-set / bitmap of held nights for overlap and sets the nights, or sets none:
-- KEYS[1] = held-nights structure for listing; ARGV = start_idx, end_idx, token, ttl
-- (nights encoded as integer day-indices; check none in [start,end) already held)
for d = start_idx, end_idx-1 do
if redis.call('GETBIT', KEYS[1], d) == 1 then
return 0 -- some night already held -> fail, set nothing
end
end
for d = start_idx, end_idx-1 do
redis.call('SETBIT', KEYS[1], d, 1)
end
-- record token+expiry for reaper; return 1 (got the whole range)
return 1
The Redis claim decides the winner in memory in microseconds; only the winner writes a held row through to the DB (where the exclusion constraint is the final arbiter). Losers fail fast without a durable write. The DB remains the source of truth; Redis is a fast front line, and if it drifts from the DB the exclusion constraint still prevents any real double-booking.
Deadlock and ordering. Because a reservation is one range on one listing (not multiple independent resources locked in sequence), the ABBA deadlock that plagues multi-seat booking largely does not arise - the claim is a single atomic operation per listing. Contention is naturally scoped per listing: two guests booking different listings never touch the same rows or keys.
TTL is the auto-release. The 15-minute hold_expires_at means an abandoned checkout self-heals: the hold expires, the range reopens. The Reaper sweeps status='held' AND hold_expires_at < now as a backstop; the TTL (and Redis key expiry) is the primary mechanism. Proactive release on explicit back-out frees dates immediately.
4. Pricing a stay
A stay’s price is not nights * sticker_price. It is a function of the exact dates, and it must be computed at search time (for thousands of results) and again authoritatively at booking time.
Naive approach: one price field on the listing, multiply by nights.
total = listing.nightly_price * num_nights + cleaning_fee
Where it breaks:
- Nightly rate is not constant. New Year’s Eve costs more than a random Tuesday in March. Weekends differ from weekdays. A host runs a seasonal calendar. A single field cannot express any of that.
- Length-of-stay and demand adjustments are missing. A 7-night stay may get a weekly discount; a high-demand weekend gets a surge. Guests, minimum-stay rules, and taxes all bend the number.
- Recomputing per search result naively is expensive if pricing calls a heavy service for each of thousands of candidates.
The answer: a per-night price calendar plus a rules pipeline, precomputed and cached.
Per-night base calendar. Store, per listing, a nightly price calendar (a value per night, like the availability bitmap - a small array over the booking horizon) set by the host’s base price plus seasonal/weekend overrides. A stay’s base is the sum of the per-night prices over [start,end), not a multiply. This is stored alongside availability and updated by the host or a demand model.
Adjustment pipeline (deterministic, ordered):
base = Σ nightly_price[d] for d in [start, end)
demand_adj = base * surge_multiplier(area, dates) -- demand model, cached per (area,date)
los_discount = weekly/monthly discount if num_nights ≥ threshold
subtotal = base * demand + los_discount
fees = cleaning_fee + service_fee
taxes = tax_rate(region) * (subtotal + fees)
total = subtotal + fees + taxes
Precompute for search, recompute for booking. At search time we need thousands of totals fast, so pricing reads the cached per-night calendar and applies cheap arithmetic in-process - no per-listing service round-trip. Demand multipliers are cached per (area, date) and refreshed periodically (they are approximate; a slightly stale surge in search is fine). At booking time we recompute the exact total authoritatively from the current calendar and rules and lock the price into the order - the guest pays what they were quoted at checkout, and the quote is snapshotted so a mid-checkout price change does not surprise them.
Dynamic pricing as an offline model, not an inline computation. The demand/surge model (occupancy in the area, lead time, events, competitor prices) runs offline and writes multipliers/nightly overrides into the price calendar. Search and booking just read precomputed prices. This keeps the hot path arithmetic-only and lets pricing evolve independently.
The principle: price is a sum over a per-night calendar plus a deterministic, ordered adjustment pipeline; heavy modeling is precomputed offline, the hot path only reads and adds, and the quoted price is snapshotted into the order so booking is authoritative and stable.
API Design & Data Schema
The read path is cacheable REST; the booking path is a small set of strongly-consistent, idempotent operations.
REST endpoints
GET /api/v1/search?bbox=&checkin=&checkout=&guests=&amenities=&price_min=&price_max=
-> { results:[ { listing_id, title, thumb, lat, lng,
nightly_from, total_price, rating } ],
next_cursor } (two-stage; availability-filtered)
GET /api/v1/listings/{listing_id}
-> { listing_id, host, photos, amenities, capacity, house_rules, rating,
min_stay, base_price } (cached, CDN)
GET /api/v1/listings/{listing_id}/calendar?from=&to=
-> { unavailable:[ {start,end} ], nightly_prices:[ {date, price} ] }
(served from availability store; eventually consistent)
GET /api/v1/listings/{listing_id}/quote?checkin=&checkout=&guests=
-> { base, demand_adj, los_discount, fees, taxes, total, currency }
POST /api/v1/listings/{listing_id}/hold
body: { checkin, checkout, guests, guest_id }
Idempotency-Key: <client-generated>
-> 200 { hold_id, checkin, checkout, quote, expires_at } (got the whole range)
-> 409 { message:"dates no longer available", conflicting_range } (overlap)
POST /api/v1/orders
body: { hold_id, guest_id, payment_method }
-> 201 { order_id, amount, payment_intent_id, state:"PENDING_PAYMENT" }
POST /api/v1/orders/{order_id}/confirm
body: { payment_intent_id }
Idempotency-Key: <payment_intent_id>
-> 200 { reservation_id, listing_id, checkin, checkout, state:"CONFIRMED" }
-> 402 { state:"FAILED", reason } (payment failed -> range released)
POST /webhooks/payment (from provider, signed, idempotent)
body: { payment_intent_id, status:"succeeded|failed", ... }
DELETE /api/v1/holds/{hold_id} -> 200 (proactive release on back-out)
# Host side
PUT /api/v1/listings/{listing_id}/calendar
body: { blocks:[ {start,end} ], nightly_overrides:[ {date, price} ], min_stay }
The hold and confirm endpoints are idempotent (via an idempotency key / the payment intent) so client or network retries never double-hold or double-book.
Data stores - the right tool per plane
1. Search index - Elasticsearch / geo-sharded inverted index. Holds slow-changing listing fields: geo (geohash / S2 cells), capacity, amenities, price band, quality signals. Sharded by geo region. Does Stage-1 candidate retrieval. Rebuilt/updated from listing changes, not from availability churn.
2. Availability + price store - Redis / columnar cache, keyed by listing_id. The fast read-side of availability and pricing:
avail:{listing_id} -> bitmap over N nights (1 = booked/blocked) # Component 1
price:{listing_id} -> array of per-night prices over N nights # Component 4
held:{listing_id} -> bitmap/sorted-set of currently-held nights, TTL-tracked
Derived from the SQL source of truth via CDC; updated in near-real-time when a reservation is confirmed. Not the source of truth - a fast filter for search and calendar reads.
3. Inventory + reservations - relational (PostgreSQL, sharded by listing_id, or a NewSQL like CockroachDB), the source of truth. Reservations are financial records needing ACID and durability, and non-overlap is a correctness invariant a schemaless eventually-consistent store cannot safely enforce. The daterange + GiST exclusion constraint enforces no-overlap in the engine itself.
Table: reservations
reservation_id BIGINT PRIMARY KEY -- Snowflake, time-sortable
listing_id BIGINT
guest_id BIGINT
during DATERANGE NOT NULL -- half-open [checkin, checkout)
status ENUM(held, booked, cancelled, expired)
hold_expires_at TIMESTAMP NULL -- for held rows
order_id BIGINT
amount DECIMAL(12,2)
created_at TIMESTAMP
EXCLUDE USING gist (listing_id WITH =, during WITH &&)
WHERE (status IN ('held','booked')) -- no two active overlaps, ever
Shard key: listing_id -- a listing's whole calendar co-located
Index: (listing_id, status), GiST(listing_id, during)
Table: listings
listing_id BIGINT PRIMARY KEY
host_id BIGINT INDEX
geo GEOGRAPHY / (lat,lng, geohash)
capacity INT
amenities JSONB / array
base_price DECIMAL(10,2)
min_stay INT
created_at TIMESTAMP
Table: calendar_overrides -- host blocks + seasonal/nightly price
listing_id BIGINT
date DATE
price DECIMAL(10,2) NULL -- override base
blocked BOOLEAN
PRIMARY KEY (listing_id, date)
Table: orders
order_id BIGINT PRIMARY KEY
guest_id BIGINT
listing_id BIGINT
hold_id UUID
payment_intent_id VARCHAR UNIQUE -- idempotency key for confirm
state ENUM(created, pending_payment, paid, confirmed, failed, expired)
quote_snapshot JSONB -- locked price the guest agreed to
amount DECIMAL(12,2)
created_at, updated_at TIMESTAMP
Index: (state, updated_at) -- reconciliation + reaper scans
Sharding note. The natural shard key for inventory is listing_id: a listing’s whole calendar, its holds, and its reservations live on one shard, so the overlap check and the exclusion constraint are always local to a single shard - no cross-shard transaction is ever needed to prevent a double-booking. The cost is that “my trips” (a guest’s reservations across many listings) fans out; solve it with a secondary reservations_by_guest read model (a second index / CDC-fed table) so the write path stays sharded by listing while guest-history reads stay cheap.
Why SQL for inventory but Redis for availability/holds: opposite needs. The reservation ledger is financial, transactional, and must never overlap - eventual consistency is exactly wrong, and the exclusion constraint gives correctness for free. Availability reads are massive-scale, tolerant of small staleness, and must answer a range predicate in microseconds - a durable per-night query per search candidate would melt the DB, so a per-listing bitmap in memory fronts it. The winner promotes from the fast in-memory claim into the durable, overlap-enforced truth. Matching the store to the plane is the whole schema decision.
Bottlenecks & Scaling
Where it breaks first, in order, and the fix for each:
1. The double-booking race (the correctness bottleneck, breaks first logically). Two overlapping reservations on one listing under concurrency.
Fix: define overlap once (aS < bE AND bS < aE) and enforce it atomically at the source of truth - a daterange GiST exclusion constraint in PostgreSQL so the engine itself refuses two overlapping active reservations, fronted by an atomic Redis range-claim for hot listings. Exactly one concurrent request wins a range, by construction; the check and the claim are one isolated operation, serialized per listing.
2. Availability search over millions of listings (the dominant load). A date-range predicate over 6M listings per search. Fix: a two-stage funnel - a geo-sharded text index does cheap availability-agnostic candidate retrieval, then a per-listing bitmap answers the date-range overlap for the candidate set in single-digit ms. Availability lives in its own fast store updated by CDC, so the fastest-changing dimension never drags down the text index.
3. Search fan-out and hot areas. Popular cities on peak weekends get hammered. Fix: shard the search index by geo region so a city search hits a few shards, not all; cache Stage-1 candidates and full results for hot (area, dates, guests) tuples with a short TTL; read replicas for calendar reads. Search scales horizontally and independently of the inventory plane.
4. Hold cleanup / stuck dates. Abandoned checkouts must not lock dates forever.
Fix: TTL on every hold (Redis key expiry + hold_expires_at, the primary mechanism, making “forgot to release” impossible), plus a Reaper sweeping status='held' AND hold_expires_at < now as a backstop. Proactive release on explicit back-out frees dates immediately.
5. Payment slowness and the TTL-vs-payment race. Hold expires mid-charge; provider times out with unknown result.
Fix: an order state machine (created -> pending_payment -> paid -> confirmed | failed | expired); freeze/extend the hold once payment starts so the range cannot vanish under an in-flight charge; make confirm a conditional, idempotent transaction keyed by payment_intent_id that re-verifies non-overlap (the exclusion constraint is the final guard) and refunds rather than double-books if the hold was lost; reconcile unknown-state payments by polling the provider before releasing or booking. Fail closed.
6. Stale search availability. Search shows a listing that was just booked. Fix: accept it - search is eventually consistent by design; the authoritative overlap check is the atomic reserve. CDC pushes booked ranges into the availability bitmap in near-real-time to keep staleness small; the guest who hits a just-taken listing simply re-searches. Correctness lives in the reservation path, not in search.
7. Hot listing / hot shard. A viral listing over a holiday concentrates contention.
Fix: shard inventory by listing_id so one hot listing is isolated to its shard and never degrades others; front hot listings with the in-memory Redis range-claim so losing attempts never hit the DB. Per-listing scope means two guests after different listings never contend.
8. Pricing cost on the hot path. Pricing thousands of search results per query. Fix: precompute the per-night price calendar and demand multipliers offline; the hot path only sums and applies a deterministic pipeline in-process - no per-listing service call. Snapshot the quote into the order at booking so payment is authoritative and stable.
9. Guest-history reads across a listing-sharded store. “My trips” fans out.
Fix: a secondary reservations_by_guest read model (second index / CDC-fed table) so the write path stays sharded by listing while guest reads stay single-shard.
10. Idempotency under retries. Clients and webhooks retry; naive handling double-holds or double-books.
Fix: idempotency keys on hold and confirm (the payment_intent_id for confirm); confirm is a no-op if the order is already confirmed; the conditional insert under the exclusion constraint is naturally idempotent. Signed, deduplicated payment webhooks.
11. Single points of failure. Fix: Redis (availability, holds, cache) runs replicated with failover; the inventory DB is replicated (sync replica for the source of truth, read replicas for calendars/search); services are stateless behind the LB. Because the DB with its exclusion constraint is the source of truth, a Redis failover at worst forces a brief fall-back to DB-only holds (slower, still correct) - never a double-booking. The system fails closed: uncertainty refuses a booking, never overbooks.
12. Consistency of Redis availability vs DB truth. The bitmap can lag the ledger. Fix: the DB with the exclusion constraint is always the arbiter - reserve and confirm re-check overlap in the transaction, so a stale bitmap can only cause a rare refused/retried booking, never a double-book. CDC keeps the bitmap close; the reaper and reconciliation resolve drift.
Wrap-Up
The trade-offs that define this design:
- Two representations of availability, each for its job. Intervals in SQL with a
daterangeexclusion constraint as the durable, overlap-enforced source of truth for booking; a per-listing bitmap in memory as the fast filter for search. Defining overlap once (aS < bE AND bS < aE) and enforcing it in the engine means correctness does not depend on application code winning a check-then-act race. - Two-stage search over one heroic query. Cheap geo/attribute candidate retrieval first, then an exact date-range availability filter and pricing - because the fields have opposite change rates and query shapes, and bolting them together lets the fastest-changing dimension drag down the whole index.
- Strong consistency only on the reservation, eventual everywhere else. Search, calendars, and quoted prices are cached and eventually consistent; only the transition of a date range into held/booked is a strongly-consistent atomic claim. We pay for linearizability exactly on the contended interval and nowhere else.
- Precomputed pricing on a read-only hot path. A per-night price calendar plus a deterministic adjustment pipeline, with demand modeling done offline, so search prices thousands of results with arithmetic and booking snapshots an authoritative, stable quote.
- Fail closed on uncertainty. When we cannot tell whether a range is free or a payment succeeded, we refuse or hold-and-reconcile rather than guess; we would rather refund a rare stuck order than ever book one listing twice for overlapping nights.
One-line summary: an Airbnb-style system where search is a two-stage funnel (geo-sharded candidate retrieval, then a per-listing availability bitmap and precomputed pricing) serving eventually-consistent reads at scale, and booking is an atomic date-range claim - fronted by an in-memory Redis hold with a TTL and enforced by a daterange GiST exclusion constraint in a listing_id-sharded SQL ledger - promoted to a durable reservation only through an idempotent, conditional, reconciled confirm, so no two confirmed stays ever overlap on a single night.
Comments