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.
Availability is where correctness lives — one row per (property, date), locked at booking. Everything else can be cache-first.
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.
| Quantity | Estimate | How you got there |
|---|---|---|
| Search QPS | ~20k/s peak | 1M concurrent × ~1 search per 30 s during travel-planning peaks. |
| Bookings/s | ~50/s peak | ~5M bookings/day globally; peak 3× average. |
| Listings | ~10M active | 5M currently listed × 2 for versions/drafts. Each with 365 dates → ~3.6B availability rows. |
| Availability rows | ~3.6B | 10M listings × 365 days. Grows linearly with catalogue. |
| Search index | ~200 GB | geo-shard + listing docs + availability bitmap. Fits ~10 ES nodes. |
| Payment volume | ~$200M/day | 5M bookings × ~$100 avg net. Escrow float becomes a treasury problem. |
The search index is not the scale challenge — availability integrity is. One row per property per date, locked with a conditional update.
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).
- 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.
Data model
Four stores by concern: listings, availability (transactional), search index (derived), bookings + payments (transactional).
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.
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.
- Guest searches → search service queries ES geo-shard for the location + date filter + guest capacity. Returns top 50 with per-window price.
- Guest picks a listing → listing service reads detail + availability window from Postgres (uncached if stale enough matters).
- Guest requests quote → booking service reads availability, computes total price, stores a soft quote in Redis with TTL 10 min. No inventory lock.
- 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.
- 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.
Deep dives — where the interview is won
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.
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.
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.
Follow-ups you should expect
How does dynamic pricing fit?
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?
What happens if two guests confirm the same dates at once?
How do you handle a rejected payment mid-saga?
Escrow: when do funds move to the host?
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.