← System design

Marketplace & Booking

Design Airbnb

Airbnb looks like a search engine with a checkout button, and most of its traffic really is search. The hard part hides in the last click: a listing is a calendar of nights, and each night can be sold exactly once. Search can be a little stale. Booking cannot, and it has to stay correct while a payment provider, a host's other booking sites and a guest's flaky connection all misbehave.
Core: date-range inventory + booking saga4.5M listings, 60M searches a daySearch eventually consistent, booking strictZero double bookings

01

What the marketplace must do

02

What the marketplace must do

Functional
  • Guests search by destination or map area, check-in and check-out dates and party size, with filters for price, room type and amenities.
  • A listing page shows photos, house rules, reviews and a total price for the chosen dates including cleaning and service fees.
  • Guests book instantly where the host allows it, or send a request the host must accept within 24 hours.
  • Hosts create listings, set nightly prices and minimum stays, and block dates on their calendar.
  • Calendars sync both ways with other booking sites through iCal feeds.
  • Either side can cancel under the listing's policy, and refunds follow that policy automatically.
Non-Functional
  • No night is ever sold twice. This is the one rule that cannot bend.
  • Search p99 under 400 ms; listing pages under 250 ms.
  • A booking confirms or fails clearly within 5 seconds, and retries never charge twice.
  • Search may show a listing that became unavailable seconds ago, as long as the booking step catches it.
  • Booking stays available during a search outage, and search stays up if booking is degraded.
Questions to ask first

Ask whether inventory is a whole home, a room or a shared bed; one unit per night is far simpler than counted inventory. Ask whether prices vary per night, since that turns the quote into a sum over dates. And ask how strict the external calendar sync must be: iCal feeds are polled, so a double booking across two sites is something you can reduce but never fully prevent. Saying that out loud is a strong signal.

02

Sizing the load

03

Sizing the load

Assume 4.5 million active listings, 150 million nights booked a year and an average stay of just under four nights. Guests search many times for every booking, mostly in the evenings of their home time zone and heavily in January and early summer.

QuantityWorkingResult
Bookings per year150M nights / 3.8 nights per stayabout 39M
Bookings per day39M / 365about 108K, 1.3 / s average
Peak booking rate8x average on a January eveningabout 10 / s
Searches per dayassumption, about 550 per booking60M
Peak search rate60M / 86,400 x 3.5about 2,400 / s
Listing page viewsabout 4 per search240M a day
Calendar rows4.5M listings x 365 nights aheadabout 1.6B listing-nights
Calendar storage1.6B x about 40 B (state, price, booking id)about 66 GB
Availability bitmap per listing365 bits46 B, about 210 MB for all listings
Photos4.5M x 25 photos x 4 sizes x 400 KBabout 180 TB
Two systems with different physics

Search is thousands of reads a second over data that may be a few seconds old. Booking is about ten writes a second that must be serialised per listing. Treat them as separate systems that share a source of truth, not one service that does both.

The calendar is small

Every night of every listing for a year fits in about 66 GB. The booking database does not need heroic sharding; partitioning by listing id over a handful of Postgres primaries is enough for years.

03

API design

04

API design

GET/v1/search?place=lisbon&checkin=2027-05-14&checkout=2027-05-18&guests=3&max_nightly=180&cursor=Search for stays
{ "results": [ { "listing_id": "lst_40912", "title": "Tiled flat above Alfama, river view", "nightly_from": 142, "total_estimate": 671, // 4 nights + fees, recomputed at quote time "rating": 4.86, "location": { "lat": 38.711, "lng": -9.130 } } ], "next_cursor": "c2VhcmNoOjIw" }
POST/v1/quotesLock a price for chosen dates
{ "listing_id": "lst_40912", "checkin": "2027-05-14", "checkout": "2027-05-18", "guests": 3 } // 200 OK. The quote is signed and valid for 15 minutes. { "quote_id": "q_7f3a", "nights": [138, 138, 155, 155], "cleaning_fee": 45, "service_fee": 40, "total": 671, "currency": "EUR", "expires_at": "2026-10-01T10:15:00Z" }
POST/v1/bookingsBook using a quote
// Idempotency-Key: 9b2e... (the client generates it once per checkout attempt) { "quote_id": "q_7f3a", "payment_method": "pm_tok_xx", "message_to_host": "Arriving late, around 22:00" } // 201 { "booking_id": "bk_55c", "status": "confirmed" } instant book // 202 { "booking_id": "bk_55c", "status": "pending_host" } request to book // 409 dates no longer available, 410 quote expired
POST/v1/listings/{listing_id}/calendar/editsHost edits nights
{ "ranges": [ { "from": "2027-07-01", "to": "2027-07-31", "nightly": 190 }, { "from": "2027-08-10", "to": "2027-08-14", "blocked": true } ] } // Blocking a night that is already booked returns 409 for that range only
POST/v1/bookings/{booking_id}/cancelCancel a stay
{ "reason": "plans_changed" } // 200 with the refund computed from the policy at booking time, not today's policy

04

Architecture overview

05

Architecture overview

Search side, booking side and the calendar between themDiagram
flowchart LR
	G["Guest app"] --> GW["API gateway"]
	H["Host app"] --> GW
	GW --> SR["Search service"]
	GW --> LS["Listing service"]
	GW --> QS["Quote and pricing service"]
	GW --> BK["Booking service"]
	SR --> ES[("Search index: geo + bitmap")]
	LS --> LDB[("Listings DB + cache")]
	BK --> CAL[("Calendar DB, partitioned by listing")]
	QS --> CAL
	BK --> PAY["Payment service"]
	BK -. "calendar.changed" .-> K[["Kafka"]]
	K --> IX["Indexer"]
	IX --> ES
	ICS["iCal sync workers"] --> CAL

The calendar database is the source of truth for whether a night is free. Only two writers touch it: the booking service and the host calendar path (including iCal sync). Everything else reads it or reads a copy derived from it.

Search never queries the calendar directly. Each listing's document in the search index carries a 365-bit availability bitmap and per-night price bands. When the calendar changes, the booking service emits an event and the indexer patches that one document within a few seconds. A date-range filter becomes a bitwise check: are all bits between check-in and check-out set? That check is cheap enough to run on tens of thousands of candidates per query.

Prices shown in search are estimates. The quote service recomputes the exact total from the calendar's per-night prices, fees and taxes when the guest opens checkout, signs it and gives it a short expiry. The booking call carries that quote id, so the price the guest agreed to is exactly the price charged, even if the host edits prices a minute later.

Search service

Geo query by city or viewport, availability bitmap filter, then ranking by quality, price fit and conversion likelihood. Stale by seconds, by design.

Quote service

Sums nightly prices, length-of-stay discounts, cleaning and service fees and local taxes. Returns a signed, expiring quote.

Booking service

The only place that converts free nights into held and booked nights. Runs the saga with payment and talks to the calendar inside transactions.

iCal sync workers

Poll hosts' external calendars every 15 to 30 minutes and block nights that were sold elsewhere, and publish our own feed for other sites to read.

Core tables

TableKeyHolds
listingslisting_idHost, address, coordinates, capacity, amenities, policy ids, instant-book flag
calendar_nightslisting_id, nightState (free, blocked, held, booked), nightly price, min stay, booking or hold id
holdshold_idListing, nights, guest, expires_at, attempt id
bookingsbooking_idGuest, listing, date range, quote breakdown, policy version, payment refs, status
booking_attemptsidempotency keyCurrent saga step, hold id, authorisation id, final response

All of a listing's nights, holds and bookings live on the same partition, chosen by listing id. A booking transaction therefore never spans partitions, which is what keeps it short and safe. Guest-facing views such as "my trips" read from a separate index keyed by guest id that is fed by booking events.

05

Deep dive: selling each night once

06

Deep dive: selling each night once

The heart of the problem

Two guests press Book on the same flat for overlapping dates within the same second. Exactly one of them must succeed, and the other must get a clean 409 without a charge. Everything in this section is about making that true under failures.

Modelling nights

Store one row per listing per night: (listing_id, night, state, booking_id, price), with state one of free, blocked, held or booked. A stay is a set of consecutive rows. To take a stay, a transaction locks those rows with SELECT ... FOR UPDATE in night order and checks that every one is free. Locking in a fixed order means two overlapping attempts cannot deadlock; the second simply waits, then sees the nights are taken.

An alternative is one row per booking with a date range and a Postgres exclusion constraint: EXCLUDE USING gist (listing_id WITH =, stay WITH &&). The database itself rejects any overlapping range, which is elegant and hard to get wrong. Per-night rows win when prices, minimum stays and blocks also vary by night, which they do here, so the per-night table is the main store and the constraint can guard the bookings table as a second line of defence.

The booking saga

Hold, pay, confirm, with a compensating pathDiagram
flowchart TD
	S["POST booking with quote and idempotency key"] --> I{"Key seen before?"}
	I -->|yes| R["Return stored result"]
	I -->|no| T["Tx: lock nights, check free, mark held with 10 min expiry"]
	T -->|conflict| C409["409 dates taken"]
	T -->|ok| P["Authorise payment for quote total"]
	P -->|declined| U["Tx: release held nights"]
	U --> F["402 payment failed"]
	P -->|authorised| B["Tx: held to booked, write booking row"]
	B --> E["Emit booking.confirmed"]
	E --> N["Notify host and guest, reindex listing"]

Never hold a database transaction open while calling a payment provider. Their p99 can be several seconds, and a lock held that long would block every other guest looking at that listing. Instead, split the work into three short transactions around one external call. First, mark the nights held with an expiry and commit. Then authorise the card. Then flip held to booked and commit again.

Each step is recorded in a booking_attempts row keyed by the idempotency key, so a retry from the client or a restarted worker resumes at the right step instead of starting over. Authorisation uses the same key with the payment provider, so a retried call never creates a second charge. A sweeper releases any hold whose expiry has passed, which covers the case where the booking service dies between steps. If the sweeper finds an authorisation with no confirmed booking, it voids it.

Capture the money later. Authorise at booking, capture around check-in, and pay the host a day after check-in. That ordering makes cancellations cheap: before capture, a cancellation is a void rather than a refund.

Pricing the quote

A quote is a small pipeline. Read the nightly price for each night of the stay from the calendar rows, apply any weekly or monthly discount the host set, add the cleaning fee once, compute the guest service fee as a percentage of the subtotal, then add occupancy taxes, which depend on the listing's address and sometimes on the number of nights. Currency conversion happens last, at a rate fixed for the life of the quote.

Keep every rule versioned and record which version produced each quote. When a city changes its tourist tax on the first of the month, quotes issued the day before must still be honoured, and support needs to explain the exact numbers a guest saw. Store the full breakdown on the booking row, not just the total, so refunds under a partial cancellation can be computed from the same lines.

Request to book

When the host must approve, hold the nights for up to 24 hours instead of 10 minutes and authorise the card at request time. Other guests see those dates as unavailable. If the host declines or the window lapses, the sweeper releases the nights and voids the authorisation. Long holds are a trade-off: they protect the guest who asked first but can block a listing for a day, so cap how many pending requests one guest can have open.

Keeping search honest enough

Search can show a listing whose last free night was booked two seconds ago. That is acceptable if the listing page and the quote both read the calendar directly and the booking transaction is the final check. What is not acceptable is search being stale by hours. Track indexer lag as a first-class metric, and when it grows, fall back to a quick calendar check on the top results before returning them.

Why not a distributed lock?

A Redis lock per listing is a common suggestion. It adds a second source of truth that can disagree with the database after a failover, and its expiry can fire while a slow transaction is still running. Row locks inside the database that owns the data give the same serialisation with none of those gaps.

06

Decisions and their costs

07

Decisions and their costs

Chosen
Per-night rows with row locks in Postgres

Prices, blocks and minimum stays are naturally per night, and locking a few rows in order serialises overlapping bookings with no extra infrastructure. Ten bookings a second is light work for this pattern.

Simple and strictly correct
Alternative
Range rows with an exclusion constraint

The database rejects overlaps for you, which is very hard to break. Per-night pricing has to live elsewhere. Good as a second guard on the bookings table.

Excellent as a safety net
Chosen
Availability bitmap inside the search index

Date filtering becomes a bitwise test per candidate, and a single document update keeps it fresh. Search can scale independently of the calendar database.

Fast filter, seconds of lag
Rejected
Join search results against the calendar DB

Always exact, but every search would fan out to the booking database and couple their availability. A search spike could slow bookings.

Couples the two halves
Chosen
Saga with holds and idempotency keys

Short transactions, no locks across network calls, clear compensation steps. Costs a sweeper and careful state tracking.

Survives partial failure
Rejected
Two-phase commit across booking and payments

Payment providers do not take part in your 2PC, and a coordinator failure leaves locks held. It solves the wrong problem.

Not available in practice
Chosen
Authorise at booking, capture near check-in

Cancellations before capture are free voids instead of refunds, and the guest's card is not charged months early. Authorisations expire after about a week, so long-lead bookings re-authorise on a schedule.

Cheaper cancellations
Alternative
Charge in full at booking

Simpler money flow and no re-authorisation, but every early cancellation becomes a refund with fees and a delay for the guest.

Fine for short booking windows

07

Failure modes

08

Failure modes

👯
Two guests book the same nights

Both requests pass the search filter and both reach the booking service within milliseconds.

The row-lock transaction lets one mark the nights held; the other sees held nights and gets a 409 before any payment call. The exclusion constraint on bookings would reject a second confirmed range even if application code had a bug.
💳
Payment authorised, confirm step fails

The booking service crashes after the card is authorised but before nights flip to booked. The guest sees a spinner and a pending charge.

The attempt row records "authorised". On restart, or when the guest retries with the same key, the service resumes and confirms. If the hold has already expired and the nights were taken, it voids the authorisation and tells the guest clearly.
📅
External calendar drift

A host's flat is booked on another site, but our iCal poll runs every 30 minutes. A guest books the same nights here in between.

Poll more often for hosts with many external bookings, encourage API-level channel integrations, and have a policy for this case: rebooking help and a credit for the guest. This cannot be fully prevented with polled feeds.
🖼️
Host uploads huge photos

A host drags in 40 RAW files of 60 MB each from a camera. Upload stalls, processing queues back up.

Upload directly to object storage with signed URLs and size limits, process asynchronously into a few standard sizes, and show the listing with placeholders until processing finishes.
🎪
Big event creates a search hotspot

A festival announces dates and a million people search the same small town for the same weekend.

Cache the first results page per query for 30 to 60 seconds, add search replicas ahead of known events, and let ranking push already-held listings down.
🕷️
Scrapers hammer search

Competitors and rate-tracking services crawl every city every hour, doubling search load.

Rate-limit per account and per IP range, require sessions for deep pagination, and serve cached pages to obvious bots.

08

How to handle the interview

09

How to handle the interview

1

Split search from booking in the first five minutes. Say one is a read-heavy, eventually consistent system and the other is a low-volume, strictly correct one. Interviewers grade on whether you see this.

2

Draw the night as the unit of inventory. Per-night rows make double booking a row-lock question. Mention the exclusion constraint as an alternative or a backstop.

3

Never call payments inside a lock. Hold, authorise, confirm, each in its own short step, with idempotency keys and a sweeper.

4

Explain how search gets availability. A bitmap per listing in the index, updated by events, gives fast date filters with bounded staleness.

5

Quote before you book. A signed quote fixes the price the guest agreed to and stops surprises when hosts change prices mid-checkout.

6

Admit what you cannot guarantee. Polled external calendars can conflict. Proposing a recovery policy shows judgement, not weakness.

09

Growing the design in stages

11

Growing the design in stages

Grow it in this order

Each step answers a specific limit. The booking database stays a plain relational store far longer than people expect.

Stage 1: a few thousand listings
One app, one Postgres

Listings, calendar and bookings in one database. Search is a SQL query with a date join. Row locks already prevent double booking.

Stage 2: hundreds of thousands of listings
Search index and caches

Move search to Elasticsearch with an availability bitmap, cache listing pages, put photos behind a CDN. Booking stays on Postgres.

Stage 3: millions of listings
Booking saga and event-driven indexing

Holds, signed quotes and idempotency keys formalise checkout. Calendar changes stream through Kafka to the indexer. The SQL date join in search is gone.

Stage 4: global
Calendar partitioned by listing, regional search

Calendar data is split across several primaries by listing id, search runs in every region, and pricing adds smart suggestions for hosts.

Watch and read

Try next

Free to read · better with landed

Whiteboard this with landed

landed runs it as a live system design round on the whiteboard and grades your trade-offs.