How Airbnb prevents double bookings
How Airbnb uses optimistic locking, calendar availability checks, idempotent booking requests, and distributed coordination to prevent two guests from booking the same dates.
The Problem Statement
Interviewer: "Two guests are looking at the same Airbnb listing. Both see it available for July 4-7. They both click 'Reserve' at nearly the same time. How does Airbnb make sure only one of them gets the booking?"
This question tests concurrency control, distributed transaction design, and whether you understand the real-world tradeoffs between user experience and data consistency. It sounds simple, but the moment you start thinking about payment holds, timezone edge cases, and multi-listing trips, it gets deep fast.
The hidden rubric: the interviewer wants to see if you can identify the race condition, propose a locking strategy, and reason about what happens when things go wrong (payment fails, user abandons, network partition).
Clarifying the Scenario
You: "Good question. I want to make sure I scope this correctly. When you say 'double booking,' are we talking about two guests booking the exact same nights, or are we also considering overlapping date ranges like July 3-6 and July 5-8?"
Interviewer: "Both. Overlapping ranges count as a conflict."
You: "Got it. And should I assume this is Airbnb's 'Instant Book' flow where the guest can book immediately, or the 'Request to Book' flow where the host has to approve?"
Interviewer: "Focus on Instant Book. That is the harder case because there is no human in the loop to catch conflicts."
You: "One more question. Should I consider the payment flow as part of the booking, or can I assume payment always succeeds?"
Interviewer: "Include payment. That is where a lot of the complexity lives."
You: "Perfect. I will structure this in three parts: how the calendar availability model works, how the system prevents concurrent bookings from both succeeding, and how the payment hold integrates with the reservation state machine."
My Approach
I would break this into four parts:
- The calendar availability model: How date ranges are stored and queried for conflicts
- The concurrency control strategy: How the system prevents two users from booking the same dates simultaneously
- The booking state machine: How a reservation moves from pending to confirmed, and what happens when payment fails
- The edge cases: Timezone handling, multi-listing bookings, and host calendar sync
The Architecture
Here is the step-by-step flow when Guest A and Guest B both try to book July 4-7:
-
Both requests arrive at the API gateway. Each request carries an idempotency key to prevent duplicate submissions from network retries or double-clicks.
-
The booking orchestrator checks availability. This is the critical step. The system queries the database for any existing reservations that overlap with July 4-7 for this listing. This query acquires a row-level lock on the listing's calendar.
-
Guest A's request acquires the lock first. The database finds no conflicts. Guest A's reservation is inserted with status
PENDING_PAYMENT. The lock is held. -
Guest B's request waits for the lock. Because Guest A's transaction holds the lock, Guest B's availability check blocks until Guest A's transaction completes.
-
Payment hold for Guest A. The system requests a payment authorization hold from the payment provider. This reserves the funds without charging them.
-
Guest A's booking is confirmed. The payment hold succeeds, so the reservation status changes to
CONFIRMED. The transaction commits and the lock is released. -
Guest B's request proceeds. The lock is released, Guest B's availability check runs, and it finds Guest A's confirmed booking overlapping July 4-7. The request is rejected with "These dates are no longer available."
-
Cache invalidation. The availability cache is updated so subsequent searches reflect the new booking immediately.
The key insight is that the database lock serializes concurrent booking attempts for the same listing. Guest B never sees stale availability data because their read is blocked until Guest A's transaction completes. This is pessimistic locking in action.
The Booking Transaction Lifecycle
A reservation is not a single event. It is a state machine with multiple transitions, each of which can fail independently.
Why the two-phase payment matters
The payment flow uses a hold-then-capture pattern. This is critical for preventing a bad user experience.
Phase 1: Authorization hold. When Guest A clicks "Reserve," the system places an authorization hold on their payment method. This reserves the funds (e.g., $500) but does not charge them. The hold typically lasts 5-7 days.
Phase 2: Capture. Once all validations pass (availability confirmed, host terms met), the system captures the held funds. The guest is charged, the host receives a confirmed booking.
The 10-minute timeout
What happens if the payment provider is slow or the guest's bank takes a long time to approve
the hold? The system cannot lock the dates forever. Airbnb uses a 10-minute timeout on the
PENDING_PAYMENT state. If the payment hold has not succeeded within 10 minutes, the
reservation is expired and the dates are released.
The timeout creates a tricky edge case. What if the payment hold succeeds at minute 11, after
the timeout has already released the dates? The system must check reservation status before
capturing. If the reservation is EXPIRED, the hold must be voided even though it succeeded.
This is why the capture step re-validates availability.
Handling Concurrent Requests
This is the core of the double-booking problem. Two strategies dominate: pessimistic locking and optimistic locking. Each has clear tradeoffs.
Pessimistic vs optimistic locking
| Aspect | Pessimistic (SELECT FOR UPDATE) | Optimistic (version check) |
|---|---|---|
| How it works | Lock rows before reading, block other transactions | Read freely, check version on write, retry if stale |
| Contention handling | Queues competing requests | Rejects and retries competing requests |
| Latency under low contention | Slightly higher (lock overhead) | Lower (no lock overhead) |
| Latency under high contention | Predictable (queue) | Unpredictable (retry storms) |
| Best for | Popular listings with frequent booking attempts | Listings with rare concurrent attempts |
| Risk | Lock timeout if transaction is slow | Livelock if many retries |
The calendar data model
The date-based availability model has two common approaches:
Per-night rows (recommended for Airbnb-style bookings):
calendar_dates table:
| listing_id | date | available | price | reservation_id |
|------------|------------|-----------|--------|----------------|
| 123 | 2026-07-04 | false | 150.00 | 789 |
| 123 | 2026-07-05 | false | 150.00 | 789 |
| 123 | 2026-07-06 | false | 150.00 | 789 |
| 123 | 2026-07-07 | true | 175.00 | NULL |
Each night is a separate row. Checking availability is a simple query: do all requested dates
have available = true? Locking is granular (lock just the requested dates). The tradeoff is
more rows (365 per listing per year), but this is trivial for a modern database.
Date range rows (more compact):
reservations table:
| listing_id | check_in | check_out | status |
|------------|------------|------------|-----------|
| 123 | 2026-07-04 | 2026-07-07 | CONFIRMED |
Overlap detection requires range comparison: new_check_in < existing_check_out AND new_check_out > existing_check_in. This is more complex to query and index correctly but
uses fewer rows. PostgreSQL's range types and GiST indexes handle this well.
Airbnb uses a per-night model because it supports variable pricing per night, minimum stay requirements per date, and blocked dates from host calendar settings. Each night is an independent entity with its own constraints.
Payment Coordination
The interaction between the booking system and the payment system is where most implementations get subtle bugs. The core challenge: the booking lock and the payment hold are two separate operations across two separate systems, and either can fail independently.
The happy path
1. Acquire calendar lock (milliseconds)
2. Insert reservation as PENDING_PAYMENT
3. Commit transaction (release lock)
4. Request payment auth hold (1-3 seconds)
5. If hold succeeds: Update reservation to CONFIRMED
6. If hold fails: Update reservation to CANCELLED, release dates
Notice that the payment hold happens after the calendar lock is released. This is intentional. Holding a database lock for 1-3 seconds while waiting for a payment provider would serialize all booking attempts for that listing, creating terrible user experience.
The failure scenarios
| Scenario | What happens | Resolution |
|---|---|---|
| Payment hold times out | Reservation stays PENDING_PAYMENT | Background job cancels after 10 min, releases dates |
| Payment hold declined | Reservation set to CANCELLED | Dates released immediately, guest prompted to update payment |
| Server crashes after lock, before payment | Reservation is PENDING_PAYMENT | Background job detects stale PENDING_PAYMENT, cancels it |
| Payment hold succeeds but capture fails | Reservation is PAYMENT_HELD | Retry capture with exponential backoff, void hold if max retries exceeded |
| Guest A books, Guest B's payment was faster | Guest A holds the lock first regardless of payment speed | Lock determines order, not payment speed |
Idempotency for payment safety
Payment operations must be idempotent. If the system sends a capture request and does not receive a response (network timeout), it cannot know whether the charge succeeded or not. Without idempotency, retrying might double-charge the guest.
Every payment request includes an idempotency key (typically the reservation ID or a UUID generated at booking time). The payment provider (Stripe, Adyen, etc.) uses this key to deduplicate requests. If the same key is sent twice, the provider returns the result of the first request without processing again.
Never generate the idempotency key on the server for each retry. That defeats the purpose. Generate it once when the booking is created and reuse it for all retries of the same payment operation.
The Tricky Parts
-
Timezone edge cases. If a listing in Hawaii (UTC-10) shows July 4 available, when exactly does July 4 start? A guest in Tokyo (UTC+9) searching at midnight their local time is looking at a different absolute moment than a guest in New York. Airbnb solves this by using the listing's local timezone for all date logic. "July 4" means July 4 in the city where the listing is located, regardless of the guest's timezone.
-
Instant Book vs Request to Book. In Instant Book, the system must be fully automated because there is no host approval step. In Request to Book, the host has 24 hours to accept, during which the dates are not locked (other guests can still request the same dates). Only when the host accepts does the system lock the dates and process payment. This creates a different race condition: two guests can request the same dates, but only the one the host accepts gets them.
-
Multi-listing trips. If a guest books a 2-week trip with Week 1 at Listing A and Week 2 at Listing B, both bookings should succeed or neither should. But these are different listings (possibly different databases or shards). A distributed transaction across two listings is expensive. Airbnb handles this as two independent bookings because the failure mode (one succeeds, one fails) is acceptable with proper UI messaging and the guest can rebook the failed portion.
-
Host calendar sync. Many hosts list on Airbnb, Booking.com, and VRBO simultaneously. They sync availability via iCal feeds. If a guest books on Booking.com, the iCal sync might take 15-30 minutes to update Airbnb's calendar, creating a window for double bookings across platforms. This requires external calendar sync polling and immediate blocking when a sync event arrives.
-
The slow searcher problem. A guest opens a listing page at 2:00 PM and sees July 4-7 available. They go to lunch. At 3:00 PM they click "Reserve." In that hour, someone else booked July 5-6. The availability check on the backend catches this, but the frontend showed stale data. Good UX requires re-checking availability when the user starts the booking flow (not just at page load) and showing clear error messages when dates become unavailable.
What Most People Get Wrong
| Mistake | What they say | Why it is wrong | What to say instead |
|---|---|---|---|
| No locking strategy | "Check availability then insert" | Classic TOCTOU race condition. Two reads can both see available | "Use SELECT FOR UPDATE to lock the calendar rows before checking" |
| Holding locks too long | "Lock the dates, then call the payment API" | Payment takes 1-3s. That is an eternity for a database lock | "Lock and insert PENDING in <50ms, then handle payment asynchronously" |
| Ignoring partial failures | "If payment fails, just delete the reservation" | What if the delete fails? What about orphaned holds? | "Use a state machine with background cleanup for PENDING reservations that expire" |
| Global lock on listing | "Lock the entire listing calendar" | This blocks bookings for non-overlapping dates unnecessarily | "Lock only the specific date rows that overlap with the requested range" |
| Skipping idempotency | "Just retry the payment if it times out" | Retrying without idempotency keys can double-charge the guest | "Every payment request carries an idempotency key (the reservation ID)" |
How I Would Communicate This in an Interview
Here is how I would actually answer this in 90 seconds:
"The core problem is a race condition: two guests checking availability simultaneously both see the dates as open, and both proceed to book.
I would solve this with pessimistic locking at the database level. When a guest clicks Reserve, the system starts a transaction and uses SELECT FOR UPDATE on the calendar date rows for that listing and date range. This locks those specific rows. Any competing booking attempt for overlapping dates will block until the first transaction completes.
Inside the transaction, I insert a reservation with PENDING_PAYMENT status and immediately commit, releasing the lock. The lock is held for about 50 milliseconds, not seconds.
Payment happens asynchronously after the lock is released. The system places an authorization hold on the guest's payment method. If the hold succeeds, the reservation moves to CONFIRMED and the guest gets a push notification. If it fails, the reservation is cancelled and the dates are released.
The state machine has cleanup: a background job checks for reservations stuck in PENDING_PAYMENT for more than 10 minutes and expires them, releasing the dates for other guests.
For the timezone question, all date logic uses the listing's local timezone. July 4 means July 4 in the city where the property is located, regardless of where the guest is searching from.
One thing people miss is idempotency. Payment requests use the reservation ID as an idempotency key so that network retries never result in double charges."
Interview Cheat Sheet
- Core problem: TOCTOU race condition. Two concurrent availability checks both return "available" before either inserts a reservation.
- Primary defense: Pessimistic locking with SELECT FOR UPDATE on calendar date rows. Lock only the specific dates requested, not the entire listing.
- Keep locks short: Hold the database lock for ~50ms (insert PENDING, commit). Never hold it during payment processing.
- Payment pattern: Auth hold, then async capture. Never charge synchronously inside a database transaction.
- State machine: INITIATED, PENDING_PAYMENT, PAYMENT_HELD, CONFIRMED, CANCELLED, EXPIRED. Background jobs clean up stale states.
- Idempotency: Every payment request carries an idempotency key (reservation ID) to prevent double charges on retry.
- Timezone rule: All date logic uses the listing's local timezone, not the guest's timezone.
- Calendar model: Per-night rows (one row per date per listing) for granular locking, variable pricing, and minimum stay enforcement.
- Optimistic vs pessimistic: Use pessimistic for popular listings with frequent contention. Consider optimistic (version column) for low-contention scenarios.
- Background cleanup: A scheduled job cancels reservations stuck in PENDING_PAYMENT for more than 10 minutes and releases the dates.
Test Your Understanding
Quick Recap
- Double bookings are caused by a TOCTOU race condition where two concurrent reads both see dates as available before either write completes.
- Pessimistic locking (SELECT FOR UPDATE) on the specific calendar date rows prevents this by serializing concurrent booking attempts for overlapping dates.
- Database locks should be held for milliseconds, not seconds. Insert with PENDING_PAYMENT status and commit immediately, then handle payment asynchronously.
- The booking state machine (PENDING_PAYMENT, PAYMENT_HELD, CONFIRMED, CANCELLED, EXPIRED) handles every failure mode, with background cleanup for stale states.
- Payment uses an auth-hold-then-capture pattern with idempotency keys to prevent double charges.
- All date logic uses the listing's local timezone to avoid cross-timezone confusion.
- Cross-platform availability sync (iCal) is eventually consistent, creating a small window for double bookings across different booking platforms.
Related Concepts
- Optimistic vs pessimistic concurrency control: The core tradeoff in this problem. Pessimistic locking prevents conflicts proactively; optimistic locking detects them retroactively.
- Distributed transactions and saga pattern: Multi-listing bookings that span multiple services use compensating transactions rather than distributed locks.
- Idempotency in distributed systems: The payment idempotency key pattern applies broadly to any operation that must be safe to retry.
- Event-driven architecture: Booking confirmation triggers downstream events (notification, calendar sync, host payout scheduling) via an event bus rather than synchronous calls.