How to Prevent Double Booking Under Concurrent Reservation Requests
An advanced guide to preventing double bookings under concurrent reservation requests — database locking, optimistic concurrency, idempotency, and race conditions.
You have tested your booking system. Availability is computed correctly. Confirmations send. Everything works. Then two users book the same slot at the same time and both get a confirmation. The problem is not your availability logic — it is concurrency, and it requires a different class of solution.
Double bookings under concurrent reservation requests are a race condition. They happen when two requests read the same availability state before either write commits. No amount of correctness in your availability computation prevents this without an explicit concurrency control strategy.
This guide covers the practical approaches for preventing double booking at the database and application layer, with trade-offs for each.
Why Concurrent Requests Cause Double Bookings
Consider the sequence:
- Request A reads availability for slot at 2:00 PM — it is free.
- Request B reads availability for slot at 2:00 PM — it is free.
- Request A writes a booking for 2:00 PM — succeeds.
- Request B writes a booking for 2:00 PM — also succeeds.
Both requests passed their availability check before either write committed. This is a classic read-modify-write race condition. The fix must make steps 1–3 atomic for a given resource and time window.
Approach 1: Pessimistic Locking
Pessimistic locking prevents concurrent access by acquiring an exclusive lock on the resource record at the start of the booking transaction. Subsequent requests for the same resource must wait until the lock is released.
In PostgreSQL, this looks like:
BEGIN;
SELECT id FROM resources WHERE id = $resource_id FOR UPDATE;
-- Re-check availability within the transaction
-- Write booking if slot is free
COMMIT;The FOR UPDATE clause locks the resource row. Any other transaction attempting to lock the same row will wait until the first transaction commits or rolls back. This guarantees that only one booking can proceed at a time for a given resource.
Trade-offs:
- Simple to reason about and implement correctly
- Works well when contention is moderate
- Can create lock contention under very high concurrent load for the same resource (rare in most booking scenarios — a single slot can only be booked once, so contention resolves quickly)
- Requires a relational database with row-level locking (PostgreSQL, MySQL with InnoDB)
Approach 2: Optimistic Concurrency Control
Optimistic concurrency control assumes conflicts are rare and handles them at write time rather than preventing concurrent reads. Each resource record carries a version number. When a write commits, it checks that the version has not changed since the record was read.
The pattern:
- Read resource record, note version
v. - Compute availability, build booking.
- Write booking with condition:
WHERE resource_id = $id AND version = $v. - If the update affects zero rows, another transaction committed first — return a conflict error.
- If the update succeeds, increment the version and return the confirmed booking.
Trade-offs:
- No lock contention — reads are non-blocking
- Requires application-layer retry logic or clear error messaging to the user
- Works in relational and document databases
- More complex to implement correctly, especially when the booking write involves multiple records
Approach 3: Atomic Slot Reservation Table
A dedicated slot reservation table makes the conflict check and the write a single atomic operation using a unique constraint.
Design:
slot_reservations (
resource_id UUID NOT NULL,
slot_date DATE NOT NULL,
slot_time TIME NOT NULL,
booking_id UUID NOT NULL,
PRIMARY KEY (resource_id, slot_date, slot_time)
)
When a booking is created, insert a row into slot_reservations. The composite primary key guarantees uniqueness — a duplicate insert fails with a unique constraint violation, which you catch and return as a conflict error.
Trade-offs:
- Extremely simple conflict detection — the database does the work
- Very fast — index-based uniqueness check is O(log n)
- Works naturally for fixed-duration slots
- Requires more schema design work for variable-duration bookings (a 90-minute booking blocks multiple slots, which requires inserting multiple rows)
Approach 4: Distributed Locks
If your system runs across multiple application servers and cannot rely on a single database for locking, distributed locks provide an alternative. Redis is the most common tool here, using the Redlock algorithm or a simpler single-node lock pattern.
The pattern:
- Attempt to acquire a lock key like
lock:resource:{id}:slot:{datetime}with a TTL. - If the lock is acquired, proceed with the conflict check and booking write.
- Release the lock after the transaction completes.
- If the lock cannot be acquired (another request holds it), return a conflict or retry.
Trade-offs:
- Works across distributed application servers without requiring a shared database connection
- Redis TTL prevents indefinite lock holds if a process dies mid-transaction
- Adds an external dependency (Redis or equivalent)
- Redlock has known failure modes under network partitions — understand the guarantees before relying on it for critical writes
The Reservation Hold Pattern
A common UX pattern is to let users hold a slot while they complete a multi-step form or payment flow. This introduces a reserved state between "available" and "confirmed."
Implementation considerations:
- Holds must expire automatically. Use a short TTL (90–120 seconds is usually sufficient). Build a cleanup job or use database TTL features to release expired holds.
- Treat a held slot as unavailable when computing availability for other users.
- The conflict prevention mechanism (lock, constraint, or distributed lock) applies at hold creation time, not just at booking confirmation time.
- If the hold expires before the user completes the flow, surface a clear message and allow them to re-hold if the slot is still available.
What to Avoid
Client-side conflict checks only: The UI can show availability, but it cannot prevent concurrent writes. Never treat the UI's availability display as the final authority.
Application-layer uniqueness checks without database enforcement: Checking for conflicts in application code before writing does not prevent race conditions — two processes can both pass the check before either commits. The enforcement must be at the database or lock layer.
Long-held locks: If your booking flow involves user interaction (form completion, payment) while holding a lock, you will block other users for the duration. Use short-lived holds with state in the database, not open transactions.
Practical Recommendation
For most booking systems:
- Use pessimistic locking (
SELECT FOR UPDATE) for booking confirmation writes — it is simple, correct, and fast enough for typical booking concurrency levels. - Use a slot reservation table with a unique constraint if you can model slots as discrete intervals.
- Add idempotency keys on booking creation so client retries do not create duplicate bookings.
These three together handle the overwhelming majority of real-world concurrency scenarios.
Building a booking system that holds up under real load is engineering work that rewards getting the fundamentals right. If you want a team that has solved these problems in production, Clixo is available to build with you.