Engineering Atlas › System Design Interviews

Design a vacation rental marketplace

19 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope: reviews, messaging, pricing recommendations, fraud and identity verification, payouts to hosts, and experiences. I would mention payments only as an external dependency with an authorize-then-capture API.

Back-of-envelope

These are planning numbers, not Airbnb's reported figures, though they are in the same order of magnitude as public statements: Airbnb reports more than 9 million active listings as of May 2026 (Airbnb), so the 8 million used below is slightly low.

Listings: about 8 million active. Bookings: assume 120 million reservations a year, which is about 330,000 per day, or roughly 4 per second on average. Seasonal and daily peaks (January planning, evenings) give perhaps 10x, so 40 bookings per second at peak. Each booking also creates and possibly releases a hold, so plan for about 100 calendar writes per second at peak, plus host calendar edits, which may be just as frequent.

Search: assume 25 million daily users doing 10 searches or map pans each, so 250 million queries per day, which is about 2,900 per second on average and perhaps 10,000 per second at peak. The read-to-booking ratio is around 1,000 to 1.

Calendar storage: a two-year booking window is 730 nights. 8 million listings x 730 nights is about 5.8 billion listing-night rows. At about 40 bytes each (listing ID, date, status, price, reservation ID) that is roughly 230 GB before indexes and replication, which is comfortable on a few dozen relational shards. Reservations: 330,000 per day x about 1 KB is 330 MB per day, about 600 GB over five years.

Photos dominate bytes: 8 million listings x 25 photos x 3 MB originals is about 600 TB, plus several resized derivatives. Over five years, with churn, think 1 to 2 PB in object storage behind a CDN.

Bandwidth: a search response of about 40 KB of JSON at 10,000 per second is 400 MB/s from the API tier. Images served from the CDN are an order of magnitude more, which is why they never touch the application servers.

API

GET  /v1/search?bbox=lat1,lng1,lat2,lng2&check_in=2026-12-20&check_out=2026-12-27
         &guests=4&price_max=300&cursor=...
  -> { results: [ { listing_id, title, lat, lng, nightly_price, total_price, rating } ],
       next_cursor }

GET  /v1/listings/{id}/availability?from=2026-12-01&to=2027-02-28
  -> { nights: [ { date, available, price } ] }

POST /v1/reservations          Idempotency-Key: <uuid>
  body { listing_id, check_in, check_out, guests, payment_method_id }
  -> 201 { reservation_id, state: "HELD", hold_expires_at, total_price }
  -> 409 { error: "DATES_UNAVAILABLE" }

POST /v1/reservations/{id}/confirm   Idempotency-Key: <uuid>
  -> 200 { reservation_id, state: "CONFIRMED" | "PENDING_HOST" }

POST /v1/reservations/{id}/cancel
  -> 200 { state: "CANCELLED", refund_amount }

PUT  /v1/listings/{id}/calendar   body { ranges: [ { from, to, blocked, price } ] }

Data model

listings (relational, partition key listing_id): host ID, title, location (lat, lng), time zone, capacity, base price, policy, status. Partitioned by listing because every booking-path query starts from one listing.

listing_nights (same shard as the listing): primary key (listing_id, night), columns status (OPEN, BLOCKED, HELD, BOOKED), price, reservation_id, hold_expires_at, version. Clustering by (listing_id, night) puts a week's rows on one or two index pages. One row per night beats one row per range because prices and host blocks are per-night anyway.

reservations (same shard, partition key listing_id): reservation_id (embedding the shard ID), guest_id, check_in, check_out, state, total_price, payment_auth_id, idempotency_key (unique per guest). Co-locating with the calendar makes "claim nights plus insert reservation" one single-shard transaction.

reservations_by_guest (partition key guest_id): a projection written asynchronously from the change stream, for the "My trips" page. It may lag by seconds, which is fine.

search index (Elasticsearch or a custom inverted index, sharded by geo cell): one document per listing with a geo_point, amenities, capacity, price, quality signals, and a 730-bit availability bitmap (about 92 bytes) where bit i means night i from an epoch is open. A date filter becomes "bits check_in to check_out minus 1 all set", which can be precomputed as a mask and tested cheaply in a script or plugin. Note that an Elasticsearch write is not searchable until the next refresh, which runs every second by default (Elasticsearch docs), so the index lags the calendar by at least that much even when the change stream is instant.

High-level design

flowchart LR
  G["Guest app"] --> GW["API gateway"]
  H["Host app"] --> GW
  GW --> SS["Search service"]
  GW --> BS["Booking service"]
  GW --> CS["Calendar service"]
  SS --> IDX["Search index (geo + bitmap)"]
  SS --> RK["Ranking model"]
  BS --> DB[("Listing shards: nights + reservations")]
  CS --> DB
  BS --> PAY["Payment provider"]
  DB --> CDC["Change stream"]
  CDC --> IDXW["Index updater"]
  IDXW --> IDX
  CDC --> PROJ["Trips projection"]
  SWP["Hold sweeper"] --> DB
  IMG["Photos in object store"] --> CDN["CDN"]
  CDN --> G

Write path (booking): the guest taps Reserve. The booking service routes on listing_id to its shard and runs one transaction that conditionally moves every night in [check_in, check_out) from OPEN (or HELD with an expired hold) to HELD, and inserts a HELD reservation with hold_expires_at = now + 10 min. If fewer rows changed than nights requested, it rolls back and returns 409. It then asks the payment provider to authorize (not capture) the total, keyed by the reservation ID. On success it runs a second transaction that flips the nights and the reservation to BOOKED or CONFIRMED, conditional on the hold still being ours and unexpired. The change stream carries those row changes to the index updater, which rewrites the listing's availability bitmap within a few seconds.

Read path (search): the search service turns the viewport and dates into a geo filter and a bitmap mask, asks the index for perhaps 2,000 candidates that pass the hard filters, computes total prices, and has the ranking model order the top few hundred. Results are cached briefly per (rounded viewport, dates, filters). The listing page reads availability from the shard or a near-current replica.

Deep dives

Claiming a date range without double booking

Options:

  1. SELECT ... FOR UPDATE on the night rows, check them in application code, then update. Correct, but holds row locks across a network round trip to the app server.
  2. A single conditional UPDATE listing_nights SET status='HELD', reservation_id=$r, hold_expires_at=$t WHERE listing_id=$l AND night >= $in AND night < $out AND (status='OPEN' OR (status='HELD' AND hold_expires_at < now())), then compare the affected row count to the number of nights. It's one statement and one round trip, and the rows are locked only for the duration of the transaction.
  3. A range table with an exclusion constraint, EXCLUDE USING gist (listing_id WITH =, stay WITH &&), which makes the database reject overlapping ranges for the same listing. The = on a plain integer column inside a GiST constraint needs the btree_gist extension; without it the constraint cannot be created (PostgreSQL docs).
  4. A distributed lock in Redis per listing. This adds a second system whose failure modes (lease expiry during a GC pause) can break the invariant unless the database also checks a fencing token.

I choose option 2. It keeps the invariant inside the database that owns the data, it costs one round trip, and per-night rows also carry per-night prices. Option 3 is fine in a Postgres shop, but host edits like "block every Tuesday" fragment ranges. What matters is that check and claim are atomic in one store.

Holds: a HELD night with an expired hold_expires_at is treated as OPEN by the claim predicate itself, so correctness never depends on the sweeper running on time. The sweeper only tidies rows and voids payment authorizations for abandoned checkouts.

Search with dates at 10,000 queries per second

Calling the calendar service for each of 2,000 candidates would mean 20 million lookups per second. Instead the index carries the availability bitmap, so date filtering happens inside the index with the geo filter. The cost is staleness: an index updater lags the database by a few seconds, so a listing booked moments ago can still appear. The booking transaction catches that and the UI offers alternatives. The bitmap must be rebuilt on every calendar change. With about 100 calendar writes per second, that is about 100 small document updates per second, which is trivial for the index. Price changes from smart pricing tools can be far more frequent. Airbnb's own write-up on embedding-based retrieval says the volume of real-time price and availability updates made an HNSW vector index's memory footprint grow too large, which is one reason it chose an IVF index whose clusters work as an ordinary search filter (Airbnb Engineering). Batch such updates per listing over a short window.

Holds, payments and request-to-book

The reservation is a small state machine: HELD, then PENDING_HOST (request-to-book) or CONFIRMED, and CANCELLED or EXPIRED as terminal states. Each transition is a conditional update on (reservation_id, state, version), so a late payment callback cannot confirm an expired hold. Payment is authorize-then-capture: authorize while HELD, capture on confirm (or at a policy-defined time), void on expiry. Every call to the provider carries an idempotency key derived from the reservation ID and the step, so a retry after a timeout replays the stored result instead of charging twice (Stripe). Request-to-book extends the hold to the host's response window (up to 24 hours) and sets the nights to HELD under that longer expiry. That still fits inside an online card authorisation, which usually stays valid for about 7 days (Stripe docs); capture-at-check-in for a booking months away does not, so long-lead bookings either capture at confirmation or re-authorise closer to the stay. Instant book skips that state.

If the second transaction finds the hold expired but the authorization succeeded, the booking service tries to reclaim the nights. If that fails because someone else got them, it voids the authorization and tells the guest. Money never moves without nights, and nights are never committed without a successful authorization.

Down the stack

Layer 1The contract

For any listing and night, at most one reservation in state HELD (unexpired), PENDING_HOST or CONFIRMED references it. A successful POST /reservations guarantees the nights are held until hold_expires_at from the database's clock, not the client's. confirm with the same idempotency key returns the same result every time. Search results are eventually consistent with a target lag of under 10 seconds. A 409 is always safe to retry with different dates; a timeout on POST is safe to retry with the same idempotency key.

Layer 2The mechanism

On Postgres, the conditional UPDATE scans the primary-key B-tree over (listing_id, night), takes a row-level lock on each matching tuple, and writes a new tuple version. If another transaction holds one of those rows, the second waits. Under READ COMMITTED, when the first commits, Postgres re-evaluates the WHERE clause against the newest row version (the EvalPlanQual recheck), so the loser sees status='HELD' with a future expiry and skips that row (PostgreSQL docs). The row count comes back short, and the application rolls back. On MySQL InnoDB the same statement behaves differently in one important way. Under InnoDB's default REPEATABLE READ, an UPDATE with a range condition takes next-key locks (the record plus the gap before it) on every index record it scans, not just on the rows that match (MySQL docs). Two guests booking adjacent, non-overlapping stays on one listing can therefore block each other briefly, and a host inserting new night rows can wait on a guest's claim. Running the booking path at READ COMMITTED largely removes the gap locks. Both engines give the "claim all or nothing" behaviour with no explicit lock table. The idempotency key has a unique index (guest_id, idempotency_key), so a duplicate insert fails, and the handler returns the stored response.

Layer 3The machine

A seven-night claim touches seven roughly 40-byte tuples that sit next to each other on one or two 8 KB heap pages, plus index pages that are hot in shared buffers. Commit appends a few hundred bytes of WAL and calls fdatasync; with many concurrent bookings, group commit lets one flush cover several transactions (PostgreSQL docs). On a datacenter NVMe drive with power-loss protection that is roughly 20 to 100 microseconds. With one synchronous replica in another availability zone, add a network round trip of about 0.5 to 2 ms. So a booking transaction costs a few milliseconds end to end, and a shard could do thousands per second. The real limit is contention: two guests on the same listing serialize on the row locks, each waiting at most one transaction duration. The 730-bit bitmap in the search document is 92 bytes, so checking a mask is two or three 64-bit AND operations per candidate.

Why it's built this way

  1. Double booking is unacceptable, so the check and the claim must be one atomic operation in one store.
  2. A single store cannot serve 10,000 searches per second over all listings with date filters, so search gets its own denormalized copy, which must then be allowed to lag.
  3. Because the copy lags, the booking path re-checks the truth, and the UI must handle "just taken".
  4. Payment providers take seconds and fail in odd ways, so a time-boxed hold, and not a long database lock, protects the dates while money moves.
  5. Locks and transactions are cheap only on one shard, so everything a booking touches is partitioned by listing_id.

Failure modes

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Per-night rows with conditional updateSimple atomic claim, carries per-night price730 rows per listing, more rows to update per stayNightly pricing and host blocks are common
Range rows with exclusion constraintCompact, overlap check enforced by the databaseRange splitting on edits, Postgres-specificMostly long stays, few per-night attributes
Distributed lock plus database writeWorks across heterogeneous storesLease expiry can break the invariant without fencingLegacy data split across systems
Availability bitmap in search indexDate filter in one queryStaleness, rebuild on every changeSearch QPS far above booking QPS
Live calendar lookup per candidateAlways freshFan-out of thousands of reads per querySmall inventories only

Leadership lens

Check yourself

  1. The hold sweeper stops running for an hour. Can a guest be blocked from dates whose hold expired, or can dates be double booked?

    Show answer

    Neither. The claim predicate itself treats a HELD night with an expired hold_expires_at as OPEN, so correctness never depends on the sweeper; it only tidies rows and voids payment authorizations for abandoned checkouts.

  2. Why does the lesson choose a single conditional UPDATE with a row-count check over a Redis lock per listing?

    Show answer

    The conditional update keeps check and claim atomic inside the database that owns the data in one round trip. A Redis lock adds a second system whose lease can expire during a GC pause and break the invariant unless the database also checks a fencing token.

  3. Why does the search index carry a 730-bit availability bitmap instead of calling the calendar service for each candidate?

    Show answer

    At 10,000 queries per second with about 2,000 candidates each, live lookups would be 20 million reads per second. The 92-byte bitmap lets the date filter run inside the index as a few 64-bit ANDs, at the cost of a few seconds of staleness that the booking transaction catches.

  4. Payment authorization succeeds, but the second transaction finds the hold has expired. What does the booking service do?

    Show answer

    It tries to reclaim the nights; if someone else has taken them, it voids the authorization and tells the guest. Money never moves without nights, and nights are never committed without a successful authorization.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →