The Blueprint
Geo & marketplaces advanced

Design Airbnb

Two-sided marketplace with availability windows, search-by-geo, price optimisation and payments — hotel-booking's harder cousin.

asked at Airbnb · Booking · VRBO Frequently asked ~40 min walkthrough ✎ Practice on canvas →
01

Scope the requirements

Split into three: search + browse, availability + booking, payments + payouts. The consistency requirements are asymmetric — search can be stale by minutes, but a double-book is a support nightmare.

Functional — what it must do

  • Host lists a property with photos, price, availability calendar.
  • Guest searches by (location, dates, guests, price) and browses ranked results.
  • Guest books a property for a date range; host confirms or auto-accepts.
  • Payment on booking, held in escrow, released on check-in.
  • Explicitly cut: messaging, reviews, host tools — offer them back.

Non-functional — the numbers that shape the design

  • Search latency p95 under 300 ms across ~10M active listings.
  • 5M listings globally, ~1M concurrent searchers at peak.
  • Zero double-booking across price/availability changes.
  • Booking commit under 2 s end-to-end including payment auth.
  • 99.99% on search; booking may degrade to retry.
Say this out loud

Availability is where correctness lives — one row per (property, date), locked at booking. Everything else can be cache-first.

02

Back-of-envelope estimates

Numbers say the search index is medium-sized (fits Elasticsearch on ~50 nodes), the availability store is small (a few TB), and payments are the smallest but strictest.

QuantityEstimateHow you got there
Search QPS~20k/s peak1M concurrent × ~1 search per 30 s during travel-planning peaks.
Bookings/s~50/s peak~5M bookings/day globally; peak 3× average.
Listings~10M active5M currently listed × 2 for versions/drafts. Each with 365 dates → ~3.6B availability rows.
Availability rows~3.6B10M listings × 365 days. Grows linearly with catalogue.
Search index~200 GBgeo-shard + listing docs + availability bitmap. Fits ~10 ES nodes.
Payment volume~$200M/day5M bookings × ~$100 avg net. Escrow float becomes a treasury problem.
Say this out loud

The search index is not the scale challenge — availability integrity is. One row per property per date, locked with a conditional update.

03

Sketch the API

Search returns listings with prices for the requested window. Booking is a two-phase call: quote first (holds price), then commit (charges + reserves).

GET/api/v1/searchparams: location, checkIn, checkOut, guests. → ranked listings with per-window price.
GET/api/v1/listings/{id}→ detail + availability + reviews (cached separately).
POST/api/v1/bookings/quotebody: { listingId, dates }. → { totalPrice, quoteId, expiresIn:600s }.
POST/api/v1/bookings/confirmbody: { quoteId, paymentMethod, idempotencyKey }. Runs the reserve+charge saga.
PATCH/api/v1/listings/{id}/calendarHost updates availability — invalidates search index rows.
  • Two-phase booking (quote + confirm): keeps the confirm call idempotent and lets the client show a fixed total before committing. Quote is a soft hold with short TTL — it does not reserve inventory.
  • Idempotency key on confirm so a double-clicked confirm returns the same booking id, never two.
04

Data model

Four stores by concern: listings, availability (transactional), search index (derived), bookings + payments (transactional).

listings
id PK · host_id · lat · lng · attrs (json) · base_price
Postgres, sharded by region for locality; medium row count.
availability
(listing_id, date) PK · status · nightly_price
Postgres, sharded by listing_id hash. One row per property per date; hot on booking.
search_docs
listing_id · geohash · attrs · price_curves
Elasticsearch, geo-sharded. Rebuilt from CDC events on listings + availability.
bookings
id PK · listing_id · guest_id · dates · price · status · payment_ref
Postgres — the record of truth for money + shipment status.
The database call

Availability is Postgres for one reason: it needs SELECT FOR UPDATE semantics on a date range at booking time. Search is Elasticsearch with CDC keeping it fresh under a minute. Bookings are Postgres because they anchor the payment saga.

05

High-level design

Search + booking split cleanly: search is a read-heavy Elasticsearch layer, booking is a write path with a saga around Postgres and the payment gateway.

client edge service data async Guest Host API gateway Search svc Listing svc Booking saga orchestrator Elasticsearch geo shards Listings PG Availability PG sharded CDC Payment PSP Event bus reserve charge
search + booking share nothing but the underlying stores. CDC glues catalogue changes to the search index.
  1. Guest searches → search service queries ES geo-shard for the location + date filter + guest capacity. Returns top 50 with per-window price.
  2. Guest picks a listing → listing service reads detail + availability window from Postgres (uncached if stale enough matters).
  3. Guest requests quote → booking service reads availability, computes total price, stores a soft quote in Redis with TTL 10 min. No inventory lock.
  4. Guest confirms → booking saga: SELECT ... FOR UPDATE the date range in Postgres → if all clear, mark as HELD → call payment PSP → on success, mark BOOKED + write booking row → emit booking.confirmed to bus.
  5. Host calendar edits flow through listing service → CDC catches the row change → search index updates within seconds; search results stay fresh without dual-write coupling.
06

Deep dives — where the interview is won

Phase 01

Availability without double-booking: SELECT FOR UPDATE on a date range

The correctness spine of the whole design. When booking commits, the saga runs a single transaction: SELECT ... FROM availability WHERE listing_id = ? AND date BETWEEN ? AND ? FOR UPDATE, then checks every returned row has status = 'available', then UPDATE ... SET status = 'held' for the range. If any date is unavailable, the whole booking aborts and returns a structured 'dates conflict' error.

The shard key is listing_id so all dates for one property live on one Postgres shard — the transaction stays local. Cross-shard bookings never happen because a booking targets one listing. The design deliberately makes availability the only strongly-consistent surface; everything upstream (search, quote) is allowed to lag.

booking availability shard Booking saga BEGIN SELECT FOR UPDATE date range UPDATE ... SET held if all avail
one transaction locks the range, verifies, commits. no cross-shard reservation possible.
Trade-offRow-locks on hot properties during a sale spike serialise bookings on that listing. Mitigate with short lock hold times (payment authorisation is not inside the DB transaction — see the saga).
Phase 02

Search stays fresh via CDC, not synchronous double-write

The naive design writes to Postgres and Elasticsearch in the same request — dual writes eventually drift when one fails. The pattern: Postgres is the source of truth, and a CDC pipeline (Debezium reading the WAL) publishes row changes to Kafka. A consumer indexes them into Elasticsearch.

Freshness lag is typically under 30 seconds under normal load. If ES falls behind, users see stale prices/availability — recoverable, not corruption. The trade-off is explicit: booking rejects at commit time will surface any stale-search miss to the user (dates no longer available), but this is rare and expected.

source cdc index Postgres WAL Debezium Kafka topic Indexer Elasticsearch
postgres commits, cdc emits, indexer applies. no application code owns both sides.
Trade-offCDC lag lets stale results into search. Booking-time validation catches it — but the UX cost is a small percentage of 'sorry, this listing became unavailable' at the last step.
Phase 03

Booking as a saga: local transaction + external payment

Payment lives at Stripe (or whoever), not in your database. You cannot hold a DB transaction open across an HTTP call to Stripe — you'd starve the connection pool. Saga: (1) Reserve dates in Postgres (fast, local), (2) Call payment gateway with idempotency key, (3) On payment success, mark booking as BOOKED and emit event; on failure, release the reservation.

The orchestrator persists state after each step so a crash mid-saga resumes safely. If the reservation succeeded and payment fails, the reservation is released and the failure is returned. If payment succeeds and the booking row write fails, retry the write — the idempotency key on payment ensures we don't double-charge on the retry.

orchestrator steps Booking saga persist state 1. Reserve dates 2. Charge PSP 3. Confirm booking Release on fail ok fail
three steps, one compensating action for failure. every step is idempotent.
Trade-offSagas expose intermediate states — a race where payment succeeded but the confirm step is retrying can show 'processing' UX for seconds. Accept it; the alternative (2PC across Stripe) is fiction.
07

Follow-ups you should expect

How does dynamic pricing fit?
Pricing model runs offline per listing per date, writes nightly to availability.nightly_price. Search reads this into ES via CDC so results reflect current price. Host can override with a manual price at any time — that write invalidates the model's suggestion for that date. No pricing runs on the request path.
Search over 10M listings with fuzzy location?
Geohash prefix + text query on the amenities. Elasticsearch native geo_point handles the radius filter; a rescore step applies the ranking model over the top few hundred results. Personalisation (preferred amenities, price sensitivity) comes from a per-user feature vector cached in Redis.
What happens if two guests confirm the same dates at once?
Serialised by row lock. First transaction wins, second gets 'dates no longer available' after their SELECT FOR UPDATE reads the updated status. The user retries with different dates or a different listing. Latency is bounded because the lock is only held during the local Postgres transaction, not across payment.
How do you handle a rejected payment mid-saga?
The saga's compensating action releases the reservation and marks the booking as PAYMENT_FAILED (audit trail). The user is prompted to retry with a different method; the same quote_id can be re-used within its TTL, so the price is unchanged.
Escrow: when do funds move to the host?
On check-in day (usually 24h after guest arrival). A batch job runs each morning, finds bookings with check_in <= today - 1, calls the PSP's transfer API. The delay lets us handle 'the room was terrible' complaints before disbursement — a business rule baked into the accounting job.

Where candidates lose the room

  • Storing availability without a per-date row — you cannot lock a range without owning the rows.
  • Dual-writing to Postgres and Elasticsearch in application code — one write fails silently under load.
  • Holding a DB transaction across the payment API call — connection pool exhaustion at first spike.
  • Serialising all bookings globally instead of by listing — a hot property blocks bookings on other listings that shouldn't share fate.
  • Confirming payment and only then reserving dates — a race lets two people pay and one gets the room.

Concepts this leans on

Found a bug or want more?

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