Design Booking & Reservations
- Scope a reservation system and pin the consistency requirement that drives every decision
- Walk the browse, hold, confirm, and cancel paths end to end with a role-colored architecture
- Go deep on the part that decides the round: stopping two guests from booking the same dates
Understanding the problem
Build the system behind a booking: a guest finds an open place, holds the dates while they pay, and walks away with a confirmed reservation the host is committed to.
This is the single most-asked design in Airbnb reports, and the most Airbnb of all of them. The whole product is one booking transaction: a guest finds a place and holds the dates, they pay, and the host hands over a confirmed stay. Get the booking right and you have touched availability modeling, money, the multi-step payment workflow, and the one concurrency bug that defines the round.
The scale is not where the difficulty lives, so put the numbers down early and move past them. Airbnb runs on the order of 8 million active listings and around 450 million nights booked a year. That last figure is about 1.2 million booked nights a day. Even if every reservation were a single night, that is under 15 writes a second on average, and a few hundred a second at a holiday peak. Tiny. A laptop could keep up. What makes this hard is that every one of those writes has to be exactly right: a night sold once, a card charged once, a host never double-committed. This is a correctness problem in a scale problem's clothes. Treat it that way.
Functional requirements
Propose a tight scope and let the interviewer redirect. The core is a short loop: a guest sees which dates are open and what they cost, holds a set of dates while they pay, and ends up with a confirmed reservation. Hosts set availability and price on the other side. Cancellation rounds it out, because a refund touches the same calendar and the same money the booking did.
In scope
- Show a listing's open dates and nightly price
- Hold dates while a guest checks out
- Confirm a reservation and charge the guest exactly once
- Let a host block dates and set price
- Cancel a reservation and refund per policy
Below the line (call it out, then skip)
- Search and ranking (its own design)
- Host-guest messaging (its own design)
- Reviews and the ratings system
- Host payouts in full (we stop at scheduling one)
- Fraud and trust scoring (named, then set aside)
Non-functional requirements
Reason through each one and attach a target, because here the requirements are the whole design. Correctness comes first, and it is non-negotiable. A set of dates on a listing can belong to at most one confirmed reservation, and a guest is charged exactly once per booking even when the network makes them click twice. The booking write path is strongly consistent. We would rather fail a booking and tell the guest to retry than sell the same week twice.
The read path is the opposite, and saying so scores. Browsing a calendar tolerates being a second or two stale. If a guest sees a date as open that someone grabbed half a second ago, the worst case is one failed booking attempt at the final step, which we handle cleanly. That relaxation lets us cache calendars hard and serve browse traffic, which dwarfs bookings, without ever touching the consistent core. Availability of the booking flow matters too, since a checkout that times out is a lost stay, but availability bows to correctness whenever the two collide.
- ~8M Active listings. Each owns its own calendar.
- ~1.2M Nights booked / day. ~450M a year, ~15 writes/sec average.
- Exactly 1 Reservation per date. The invariant the round tests.
- Strong Write consistency. Browse can be stale; the booking cannot.
The set-up
Core entities
Name the objects the system moves around before drawing anything. Four carry this design:
Listing
The place, owned by a host: an id, a location, plus the booking rules (max nights, how far ahead it can be booked, instant-book on or off).
Availability
A listing's calendar: for each date or date range, is it open, held, or booked, plus the nightly base price the host set. The source of truth the booking write fights over.
Reservation
One booking: a guest, a listing, a check-in and check-out date, a total price, plus a status that walks from requested to confirmed to cancelled.
Payment
The money attached to a reservation: an authorization, a capture, then later a refund or a host payout. Idempotency-keyed so a retry never charges twice.
One split matters most, between the calendar and the reservation. A calendar is a per-listing structure asked one question constantly ("are these nights open?") and it is the row everyone contends over. The reservation is the durable record of a completed booking, written once and read for receipts and trip management. Both want a relational store with real transactions. The invariant we care about is one reservation per date, and a database constraint plus a row lock enforce that on their own. Postgres is the name everyone in the room knows, and it gives us SELECT ... FOR UPDATE and unique constraints, inside a transaction that either commits whole or not at all. Partition both by listing_id so one listing's calendar and its reservations live on the same shard. A single booking then touches one shard in one transaction. Say that partition key out loud. The interviewer asks.
API
The booking flow is two steps on purpose: a hold, then a confirm. Splitting them is what lets a guest take ten minutes to enter a card without the dates leaking out from under them, and it is the seam where idempotency keys go. Both write calls carry an Idempotency-Key header so a client that retries after a timeout gets the original result back instead of a second hold or a second charge.
High-level design
Build it one path at a time, naming the bottleneck as each surfaces. The system splits along a consistency line rather than a read/write line. One path is a heavy, relaxed browse that can be cached and served stale. A small, strict booking path is the other, where every write has to be exactly right. The diagram codes each box by role, blue for people, violet for our services, amber for the event log, green for storage. The walkthrough below tags each component the same way.
1) Browse the calendar. The Guest calls GET /listings/{id}/calendar against the Calendar service, which serves the listing's open dates from a cache in front of the Availability DB. This is almost all of the traffic. For every booking, the same dates get viewed hundreds of times by guests scrolling search results and opening listings. Reads run on the order of thousands per second. The write path sees a few hundred per second at its worst. We let these reads lag the truth by a second or two on purpose. The only cost of showing a date that was grabbed half a second ago is one rejected attempt at the final confirm, which we handle cleanly. Serving browse from cache is what keeps this firehose off the date rows that the booking path locks and contends over, the load the second deep dive sizes.
2) Price the stay. When the guest picks dates, the client calls POST /listings/{id}/quote. The Pricing service reads the host's nightly base prices from the Availability DB, then adds the seasonal and demand adjustment, the cleaning and service fees, the local taxes, plus any currency conversion. It returns a quote that is frozen and stored under a quoteId for a short window, roughly ten to fifteen minutes. The frozen number is the exact price the guest will be charged. It can't drift if the host bumps the rate or the exchange rate moves between the guest seeing the total and paying it. The pricing math itself stays off the booking write path entirely, so a slow pricing call never holds a lock. The third deep dive builds the layered pricing model and the multi-currency case out.
3) Hold the dates. The guest calls POST /holds, and the Booking service asks the Calendar service to claim the dates. Inside one transaction it locks the listing's affected date rows, checks they are all still open, and flips them to held with a short expiry on the order of ten minutes. That whole locked write runs in a few milliseconds, so the lock is gone almost as soon as it is taken. The guest now owns those nights long enough to enter a card, and no one else can hold or book them. If the dates were already taken, the call fails right here, before any money is involved. The naive version of this step, a read-then-write with no lock, is exactly the double-booking bug, and untangling it is the first deep dive.
4) Confirm and pay. The guest submits payment and the client calls POST /reservations with the holdId and an Idempotency-Key. The Booking service checks the hold is still valid and asks the Payment orchestrator to charge the guest through the Payment gateway, a call that takes a second or two and can fail. On success it writes the Reservation DB row and flips the held dates to booked. Every step is keyed, so a retry cannot double-book or double-charge; then it drops one event on the Event log and returns. Everything after the charge happens off that event, never on the guest's request: the host payout, the confirmation emails, the search-index update, all of it, which keeps the booking transaction small. Sequencing the database write against an external charge and a calendar update with no distributed transaction is the fourth deep dive.
5) Host writes and cancellation. A Host opening or blocking dates hits the same locked calendar write a guest hold does, because a host block and a guest hold race for the exact same date rows. These writes are rare next to booking; a host edits a calendar a handful of times a week. They're strict for the same reason, though: a host must never block a night a guest already holds. A cancellation runs the booking in reverse: refund through the Payment orchestrator per the listing's policy, flip the dates back to open, and emit a cancellation event so the search index frees them again. Both paths reuse machinery the booking already built, the locked write and the keyed payment steps, which is the sign the core is modeled right. The reservation states this walks through are the fourth deep dive.
Capacity check
The write rate we already sized: about 1.2 million booked nights a day is roughly 15 bookings a second on average, and a few hundred a second at a holiday peak. Each one is a single short transaction on a single shard, so a commodity Postgres primary handles it with room to spare. The interesting number is the gap to the read side. For every booking, the same dates get viewed hundreds of times by guests browsing search and opening listings. Read traffic lands on the order of thousands per second, two to three orders of magnitude above the writes. That ratio is why browse gets its own cached, relaxed path and never touches the date rows a booking locks.
Storage grows slowly and we keep it forever. Around 450 million reservations a year at roughly 1KB each (guest, listing, dates, total, status, the frozen price breakdown) is about half a terabyte a year. The payment records attached to them add a comparable slice. After a decade that is single-digit terabytes, small enough that retention is never the constraint: we hold reservations indefinitely because a receipt or a dispute months later replays them. For the cache, a listing's calendar stored as the compact date ranges of deep dive two is about a kilobyte. Caching all 8 million is on the order of 8GB, which fits in one Redis cluster and absorbs the entire browse firehose.
- Write rate: ~15/sec avg. 1.2M booked nights a day, a few hundred per second at a holiday peak. Each is one short transaction on one shard, trivial for a single Postgres primary.
- Read:write skew: ~100x. Hundreds of calendar views per booking puts browse in the thousands per second. That gap is why browse runs on a cached, second-stale path of its own.
- Reservation storage: ~0.5 TB/yr. 450M reservations a year at ~1KB each, plus payment records. Single-digit TB over a decade, kept indefinitely for receipts, refunds, and disputes.
- Calendar cache: ~8 GB. 8M listings at ~1KB each as compact date ranges. Fits one Redis cluster and serves the browse firehose without touching the Availability DB.
Technology choices
Every box so far is a category. In the room, name a real tool for each and be ready for the failure question, because that is where this round goes. What happens when the payment gateway times out, or a guest closes the tab mid-hold, or two confirms land in the same millisecond?
- The Booking service, Calendar service, and Pricing service run as stateless servers behind a load balancer. Stateless means no server keeps anything between requests that a later request depends on: no in-memory hold, no session, no "this guest is mid-checkout" flag living in process memory. We do that deliberately, because it makes the servers interchangeable: any box can serve any call, the load balancer spreads traffic evenly, and we scale out by adding boxes. The probe this invites is "what happens when a server dies mid-booking?" Because it held no state, nothing is lost: the in-flight transaction rolls back in the database and the client retries against another box. The idempotency keys live in Postgres, not in a server's memory, so the retry lands on the same key and returns the original result instead of booking a second time.
- The Availability DB and Reservation DB are Postgres, partitioned by
listing_id. A relational store fits because the invariant is a constraint. Row locks (SELECT ... FOR UPDATE) and a unique constraint on (listing, date) enforce one-reservation-per-date inside a transaction that commits whole or rolls back. Partitioning by listing keeps a booking on one shard, so it stays a fast local transaction. The failure question, a shard's primary dying mid-booking, is answered by a synchronous replica that gets promoted; an in-flight transaction that had not committed simply rolls back, and the guest retries. - Holds expire, and there are two honest ways to do it. Store the expiry as a column on the held rows and have the claim treat any hold past its expiry as open, plus a sweeper that flips abandoned holds back to open. Or keep holds in Redis with a native TTL so they vanish on their own. The column approach keeps one source of truth in Postgres, so a hold and a booking can't disagree across two stores. The consistent path prefers it, while Redis is the lighter option when hold volume is high. Either way, the expiry is what stops a closed browser tab from locking a week forever.
- The Payment gateway is an external processor, and the name to put on the board is Stripe (or Adyen). It is the right category because we never want to hold raw card numbers or wire up our own connection to the card networks; the processor does the regulated, risky part. The Payment orchestrator wraps it and drives each charge as a sequence of idempotent, retryable steps. Authorize checks the funds exist and reserves them. Capture moves the money, and later comes a refund or a host payout. Every step carries the booking's idempotency key, so running it twice is a no-op. The probe this invites is the timeout. The gateway is slow and occasionally lies, returning an error on a capture that secretly went through. The orchestrator never assumes the outcome. It retries under the same key and reconciles against the processor's own record, so a timed-out capture is detected rather than charged a second time.
- The Event log is Kafka, partitioned by reservation id. One confirmed booking writes one event, and notifications, payouts, and the search index each read it independently, so a slow email service can never wedge a booking. The log keeps history, so a consumer that crashes rewinds to its last bookmark and replays; events are keyed so a replayed booking event does not send a second confirmation email. To bridge the gap between committing the reservation and publishing the event, write the event to an outbox table in the same transaction. Relay it to Kafka after commit, and a crash between the two can never lose or invent a booking.
- Browse reads sit behind Redis, keyed by listing and month, because a calendar gets read hundreds of times for every change. We already decided a browse may lag the truth by a second, so serving it from memory instead of Postgres costs almost nothing in correctness. We keep hit rates high with a longish TTL plus an active invalidation: the booking event refreshes the affected listing's cached months the instant its dates flip. The probe is the cache stampede, where a hot listing's entry expires and a thousand viewers miss at once and stampede the database together. The answer is to coalesce those misses so a single request refills the entry while the rest wait on its result. This is the cache that absorbs the browse firehose and keeps it off the Availability DB.
Potential deep dives
Most of the interview signal lives here. The interviewer picks one or two pieces and pushes. These are the ones Airbnb leans on, and the first one carries most of the round.
1) How do we stop two guests booking the same dates?
This is the signature Airbnb probe, and a strong loop can turn on it alone. Picture a cabin that is perfect for an eclipse week, and two guests both click Book on the last open dates inside the same second. The volume is nothing, one extra request, but both requests are aimed at the exact same date rows. The whole job is making sure precisely one of them wins, the other gets a clean "those dates are gone," and nobody is charged for a stay they will not get.
Approach
Check the dates are open with a SELECT, and if they are, INSERT the reservation. It reads like the obvious thing and works perfectly in a demo with one user.
Challenges
Both racing requests run their SELECT before either has written. Both see the dates as open. Both INSERT. Now one listing has two confirmed reservations on the same week and two cards are charged, and you find out when a host has two families at the door. The gap between the read and the write is the entire bug, and nobody should propose this. Being able to explain *why* it fails is the point of the question.
Approach
Wrap the claim in a transaction that locks the listing's affected date rows with SELECT ... FOR UPDATE before reading their status. The first request to arrive takes the lock; the second blocks until the first commits, then reads the dates as taken and is rejected. A unique constraint on (listing, date) is the backstop: even if two writes somehow slip through, the database refuses the second one. Because we partitioned by listing_id, all of this is one local transaction on one shard, which is why a write rate this low is a non-issue.
Challenges
The lock is held for the length of the transaction, so the claim must be quick: lock the dates, flip them to held, commit, all in milliseconds. The slow part, the guest entering a card, must happen *outside* the lock. That is exactly why we split hold from confirm: the hold takes and releases the lock fast, and the ten minutes of checkout happen against an already-held row that no lock is sitting on.
Approach
On top of the lock sit two layers, starting with the soft hold: the fast locked write flips the dates to *held* with an expiry around ten minutes. The guest owns the nights long enough to pay, and everyone else sees them as taken. If the guest abandons checkout, the hold expires and the dates reopen on their own, no human and no stuck inventory. Second, idempotency keys: every POST /holds and POST /reservations carries a client-generated key, stored with the result. A guest on a flaky phone who taps Confirm twice, or a client retrying after a timeout, hits the same key. The second call returns the first call's result instead of making a second hold or charge. The race between two guests is settled by the lock; the race between a guest and their own retry is settled by the key.
Challenges
You now have to expire holds reliably (a TTL column the claim respects, plus a sweeper) and store idempotency keys with a sensible retention so they cover the retry window without growing forever. Both are small. This is the bar because it handles both real concurrency cases at once: two different guests, and one guest hitting submit twice. It never holds a database lock across a human's checkout.
The handling contention lesson covers row locks and idempotency keys in depth, including the optimistic-concurrency variant.
2) How do we store and serve the availability calendar?
Size the obvious design first. A listing can be booked up to a year or two out, so call its calendar 730 days. Store one row per listing per day and you have 8 million listings times 730 days, about 6 billion rows, almost all of them just saying "open." That works, but it is a lot of rows to write a host block across and a lot to cache. The fix is to notice that a calendar is mostly long unbroken stretches of the same state.
Approach
A table keyed by (listing, date), each row carrying a status and a nightly price. Querying a stay is a range scan over a handful of dates, and booking flips a handful of rows. It is simple and the per-date price is easy to model, since price already lives per day.
Challenges
It is ~6 billion mostly-redundant rows, and a host opening "all of next summer" rewrites a hundred rows for one action. Cacheable, but heavy. For most listings, which sit open for long stretches, you are storing the same fact thousands of times.
Approach
Store availability as ranges: a row says "March 1 to June 30, open, $120 a night." A booking splits a range (the booked nights become their own *booked* range, the open remainder stays open) and a host price change splits it the same way. A mostly-open listing is a few rows instead of 730. Reads are still a simple overlap query: find the ranges touching the requested window. Booking still locks and splits inside one transaction, so the concurrency story from deep dive one is unchanged; we have only made the rows fewer and the host edits cheaper. Keep the per-day price as an override layer for the days a host tuned individually, so the common case stays a range and only the exceptions expand.
Challenges
Range splitting is more code than flipping a flag. An off-by-one on an inclusive versus exclusive check-out date is a classic bug: the night of check-out isn't occupied, so ranges meet at the checkout date. The payoff is far fewer rows and cheap host edits, with a calendar small enough to cache per listing and serve the browse firehose from memory.
The two consistency levels meet here. The host block and the booking claim go through the strict locked write on the Availability DB. Browse reads come off the cache, refreshed on the booking event, and are allowed to lag by a second. That single decision, strict on the claim and relaxed on the browse, is what lets one calendar serve millions of casual viewers and a handful of contending bookers without either side slowing the other.
3) How does pricing handle multi-night stays and currency?
A nightly number on the calendar is not what a guest pays. A four-night August stay in a beach town is the sum of four nights that each cost differently. On top sit a cleaning fee, a service fee, taxes that depend on the city, and a conversion if the guest pays in a different currency than the host earns in. The interviewer wants two things. Separate the host's input from the computed total, and freeze that total at the right moment.
Model price in layers. The host sets a base nightly price per date, which lives on the calendar ranges from deep dive two. On top sits a pricing engine that applies adjustments. Seasonal and demand multipliers make a festival weekend cost more, and length-of-stay discounts make a week cheaper per night. The host's own rules apply too, and then come the fees and taxes, which are additive and depend on the listing's location. Last is currency, converted at quote time at a rate you record alongside the quote.
Approach
POST /quote runs the pricing engine once and stores a frozen quote under a quoteId with a short lifetime: the total, the full breakdown, and the exact FX rate used. Checkout carries that quoteId into POST /reservations, and the charge bills the frozen total. The guest is charged exactly what they were shown, even if the host raises the price or the exchange rate moves in the ten minutes between seeing the number and paying. The breakdown is stored on the reservation so a receipt, a refund calculation, or a dispute all replay the same arithmetic.
Challenges
Quotes expire, so a guest who dawdles past the window gets re-priced, which is correct but needs a clear message. And demand-based pricing wants near-real-time signals (how many people are viewing these dates), which is a separate pipeline feeding the engine. The booking path never computes it inline.
4) What is the reservation lifecycle, and how do we keep money and calendar in sync?
A reservation is a small state machine, and a booking is a saga across three systems that share no transaction: the calendar, the payment processor, and the reservation record. The interviewer wants the states named and the failure-in-the-middle handled, because that middle is where real money goes wrong.
The states walk in order. A booking is requested when the hold is placed. With instant book on, it goes straight toward payment; with request to book, it waits on the host and can be accepted or declined, with the hold keeping the dates safe in the meantime. On acceptance the card is authorized, then captured, and the reservation becomes confirmed. From confirmed it can later go cancelled (by guest or host) and refunded per policy. Each transition is a single, recorded step, never a flag flipped in two places at once.
Approach
No single transaction spans Postgres and an external card processor. Drive the booking as a saga: an ordered list of idempotent steps, with a compensating action if a later step fails. Authorize the card; if that fails, release the hold. Capture; if the reservation write then fails, void the capture. The Payment orchestrator owns this sequence and persists where it is in the list, so a crash mid-booking resumes from the last completed step rather than restarting and re-charging. Because every step is keyed, resuming and retrying are safe by construction.
Challenges
The hard case is the authorize or capture that times out, where the processor may have done it or not. The orchestrator cannot guess, so it reconciles, querying the processor under the idempotency key to learn the true outcome, and only then advances or compensates. Reconciliation, far more than the happy path, is what separates a real answer from a whiteboard one, and it is the operational mindset the senior bar is checking for.
The async processing lesson covers sagas and the outbox pattern that sit underneath this.
5) What breaks in production, and how does it recover?
Once the happy path is solid, a senior interviewer circles to the 4am questions: what fails, and does the system heal without a human. Each answer leans on a decision already made.
- The guest closes the tab mid-checkout. Nothing leaks. The hold carries a TTL, so the dates reopen on their own when it expires, and the sweeper catches any hold the claim path missed. No stuck inventory, no support ticket.
- The payment gateway times out on a capture. The orchestrator never assumes. It retries under the same idempotency key and reconciles against the processor's record, so a capture that secretly succeeded is detected rather than repeated, and one that truly failed releases the dates. A timed-out charge is the most dangerous failure in this system, and reconciliation is the main defense.
- A shard's database primary dies mid-booking. Postgres promotes a synchronous replica. Any transaction that had not committed rolls back cleanly, which for a booking means the dates were never claimed and the guest simply retries. Partitioning by
listing_idkeeps the blast radius to one shard's listings. - The booking commits but the event never publishes. The outbox closes this dual-write gap. The booking event is written to an outbox table in the same transaction as the reservation, and a relay ships it to Kafka after commit. So a confirmed booking always has its event waiting, and a crash between the two cannot lose a notification or a payout, nor invent one for a booking that rolled back.
- A downstream consumer falls behind or replays. Kafka keeps the log, so a crashed notification or payout consumer rewinds to its bookmark and catches up. Events are keyed by reservation, so a replayed booking event does not send a second confirmation email or schedule a second payout. Lag stays a freshness delay. Nothing is lost or duplicated.
- A whole region goes down. Browse degrades gracefully on stale cache, which is acceptable for reads. The booking path needs its consistent store reachable, so replicate to a standby region with a clear, tested failover. A few in-flight bookings fail and retry, which beats risking a double-sell across regions.
What is expected at each level
That is a lot of surface area. Here is how much you need to land, by the level you are interviewing for. Airbnb's bar scales hard with level, and design is one of the rounds where the leveling committee adjusts a candidate up or down.
G8 (mid)
Breadth-led, about 80/20. Reach a working end-to-end booking: a calendar in a relational store, a two-step hold-then-confirm flow, a reservation record, a payment step. You should know what each box does when probed and you should recognize the double-booking risk when it is raised, even if the interviewer drives you to the locked-write fix. The bar: a complete flow you can explain, with the concurrency hole found together.
G9 (senior)
Now depth, roughly 60/40, and this is the level that most often turns on this design. You raise strong consistency on the write path before being asked, and you reach the soft-hold-plus-idempotency-key answer on your own, naming both races. You defend the partition key, and you handle a payment timeout with reconciliation rather than a hopeful retry. The bar: a near-optimal booking core where you found the concurrency and money failures yourself. More senior candidates are sorted here than anywhere.
G10+ (staff)
Depth leads as the scope widens, roughly 40/60. Expect the saga and its compensations, the outbox and the dual-write gap, multi-region failover and what you sacrifice to avoid a cross-region double-sell, and pricing as its own subsystem with demand signals feeding it. You are reasoning about how the system degrades and heals, and you are driving. The bar: you run the session and the interviewer steers rather than rescues, and you can defend every consistency choice as deliberate.
Key Takeaways
- ✓Lead with consistency. The write rate is tiny, but the booking path must be strongly consistent: a set of dates belongs to exactly one reservation, and a card is charged exactly once.
- ✓The two-step hold-then-confirm flow is the heart of it. A fast locked write places a soft hold with a TTL, and idempotency keys on both writes settle the guest-versus-retry race; the lock settles the guest-versus-guest race.
- ✓Store the calendar as date ranges in Postgres partitioned by listing id, and serve browse from a cache that can lag a second. The host block and the booking claim stay on the strict locked write.
- ✓A booking is a saga across calendar, payment, and reservation with no shared transaction. Drive it as ordered idempotent steps with compensations, reconcile against the processor on a timeout, and fan out via an outbox to Kafka, the shape Airbnb describes in Orpheus.
Airbnb interview guideKnow what Airbnb asks before you walk in.
18 coding · 5 system design · 20 behavioral questions
Get their questions first.
InterviewLegend is independent and not affiliated with or endorsed by any company it covers.