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.
Why double bookings are hard
Two guests can read the same availability snapshot and submit overlapping reservations before either write is visible to the other. The page-level availability check is therefore advisory; the booking write must perform an authoritative conflict check and reserve the dates as one atomic database operation.
Payment makes the problem distributed. A calendar database, a payment provider, notifications, and external calendar feeds can all succeed or fail independently. A production design needs short-lived calendar protection, an explicit reservation state machine, idempotent retries, and cleanup for work that stops halfway through.
Scope and assumptions
The main example is an Instant Book-style flow for a single listing, where overlapping nights are a conflict and the listing's local timezone defines the dates. The discussion also covers payment authorization, host blocks, Request to Book, multiple listings, and external calendar sync. The exact lock and timeout policy is product-specific; the important invariant is that two confirmed reservations cannot overlap for the same listing.
30-second mental model
- Read optimistically: Search and listing pages can use a cache, but the result may become stale.
- Reserve authoritatively: In one short transaction, lock the relevant calendar rows, verify
every requested night, insert a
PENDING_PAYMENTreservation, and mark the nights unavailable. - Coordinate asynchronously: Release the database lock before calling the payment provider; advance the reservation through idempotent state transitions when payment callbacks arrive.
- Recover: Expire abandoned reservations, void late payment holds, reconcile uncertain outcomes, and publish cache invalidations after the database commit.
The key invariant is simple: availability is decided at the write boundary, not by the stale read that rendered the calendar.
The system can be understood in 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
Five-minute end-to-end flow
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. This example uses a 10-minute timeout on the
PENDING_PAYMENT state; the actual window should match the payment provider's behavior and the
product's checkout experience. If the payment hold has not succeeded within the window, 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.
A per-night model is useful 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.
Bottlenecks and failure modes
-
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. Use 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. A platform may handle these as two independent bookings because the failure mode (one succeeds, one fails) can be made explicit in the UI and the guest can rebook the failed portion. An all-or-nothing experience would require a coordinator and compensating actions.
-
Host calendar sync. Many hosts list on multiple booking services simultaneously. They may sync availability via iCal feeds. If a guest books on one service, the feed sync might take several minutes to update another 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.
Common mistakes
| 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)" |
Practical checklist
- Make the database (or another explicitly chosen authoritative store) the final source of truth for overlapping nights.
- Pre-create per-night rows, or use a database exclusion/unique constraint that also protects the case where no calendar row exists yet.
- Lock only the requested listing/date rows, keep the transaction short, and never call a payment provider while holding the lock.
- Persist a reservation state machine with
PENDING_PAYMENT,PAYMENT_HELD,CONFIRMED,CANCELLED, andEXPIREDtransitions. - Use one idempotency key per booking and per payment operation; reuse it across retries and webhook handling.
- Expire abandoned reservations, release nights atomically, and reconcile late payment callbacks before confirming or refunding.
- Define the listing timezone, check-out semantics, minimum-stay rules, host blocks, and external-calendar consistency window.
- Monitor lock wait time, conflict rate, stale pending reservations, payment uncertainty, cache freshness, and reconciliation failures.
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.