Ticket Booking
The double-booking problem — holds, optimistic versus pessimistic locking, and surviving a sale where a million people want the same thing
This design has one hard requirement that everything else bends around: a seat must never be sold twice. It also has the nastiest traffic shape in this section — near-zero load, then a million people at exactly 10:00.
1. Clarify and Scope
| Question | Assumption taken here |
|---|---|
| What is booked? | Specific numbered seats, not general admission |
| Hold before payment? | Yes — 10 minutes to complete checkout |
| Overselling? | Never acceptable |
| Traffic shape? | Extreme spikes at announced on-sale times |
2. Put Numbers On It
NORMAL LOAD
100,000 bookings/day ÷ 100,000 sec ≈ 1/sec ← nothing
ON-SALE SPIKE
50,000 seats, 1,000,000 people trying
Most arrive within the first 60 seconds
1,000,000 ÷ 60 ≈ 17,000 req/sec
Browsing and refreshing ≈ 100,000 req/sec
Peak-to-average ratio ≈ 100,000 : 1The traffic ratio is the design problem
A system sized for the average is 100,000 times too small for the moment that matters. A system sized for the peak sits idle almost always.
Worse, 95% of that traffic must fail — there are 50,000 seats and a million people. The system's real job is to reject most requests quickly and correctly, without falling over and without selling the same seat twice.
Everything below follows from that.
3. Define the Interface
list_seats(event_id) → seat map + availability
(cached, allowed to be stale)
hold(event_id, seat_ids[], user) → { hold_id, expires_at }
| { unavailable: [seat_ids] }
confirm(hold_id, payment_token) → { booking_id }
release(hold_id) → seats freed immediatelyBooking is two calls, not one — this is the key move
The obvious interface is book(seats, payment). It cannot work here.
Taking payment takes seconds: a card network round trip, possibly a bank's confirmation screen. During those seconds the seat must belong to nobody else. A single book() leaves two options, both bad:
Single book() does… | Result |
|---|---|
| Hold a database lock while the card clears | Rows locked for seconds, during a 17,000/sec surge |
| Charge first, assign the seat after | Sometimes charges for a seat already gone |
Splitting it fixes both. hold is fast and takes the seat off the market. confirm runs afterwards with no contention, because the seat is already the caller's. The expires_at returned by hold is what stops an abandoned checkout from keeping the seat forever.
Turning one slow operation into a fast claim plus a slow completion is the general move. It appears again as reservations in payments and as leases in distributed locks.
`list_seats` is deliberately allowed to be stale
It is read tens of thousands of times per second and it never decides anything. Only hold decides, and hold reads fresh.
So the crowded path can be cached hard while correctness lives in one small operation. Showing a seat that was taken a second ago costs a retry; making that page strongly consistent would cost the system.
4. The Double-Booking Problem
Two users click the same seat at the same instant.
Checking then writing is a race
User A: SELECT status FROM seats WHERE id=42 → available
User B: SELECT status FROM seats WHERE id=42 → available
User A: UPDATE seats SET status='sold' → ok
User B: UPDATE seats SET status='sold' → ok
One seat, two tickets, two angry customers at the door.The gap between reading and writing is where the bug lives. Any solution must close it.
Three ways to close it
| Approach | How | Trade-off |
|---|---|---|
| Pessimistic lock | SELECT … FOR UPDATE — lock the row, then write | Correct; locks held across a slow request |
| Optimistic lock | Conditional update; retry if it affects 0 rows | Correct; no locks; retries under contention |
| Unique constraint | A unique index on (event_id, seat_id) | The database enforces it absolutely |
Optimistic locking is the right default here
UPDATE seats
SET status = 'held', held_by = ?, held_until = now() + 10 min
WHERE id = ? AND status = 'available'If this affects zero rows, someone else took it — tell the user and offer another seat. If it affects one row, it is theirs.
The check and the write are one atomic statement, so there is no window. And because no lock is held while the user thinks, a slow user never blocks anyone.
Contention is high during a sale, but the loser learns instantly and retries elsewhere, which is far better than waiting on a lock.
Add the unique constraint as well. Application logic can have bugs; a database constraint cannot be bypassed by any code path.
5. Holds
A user needs time to pay. During that time the seat must be unavailable to others — but not permanently, or an abandoned checkout removes the seat forever.
Do not rely only on a background job to release holds
If the sweeper falls behind or dies, seats stay held and the event appears sold out while thousands of seats sit unclaimed.
Make the availability check itself expiry-aware:
WHERE status = 'available'
OR (status = 'held' AND held_until < now())Now correctness does not depend on the sweeper running. The sweeper becomes a tidy-up job rather than a critical component — which is exactly what background jobs should be.
6. Surviving the Spike
Correctness is solved. Now the system must stay up while a million people arrive at once.
| Technique | Effect |
|---|---|
| Virtual waiting room | Admit users in controlled batches; everyone else waits on a static page |
| Pre-scale | On-sale times are known in advance — scale up beforehand |
| Static everything | Event pages, images and seat maps come from a CDN |
| Queue the browsing, not the buying | Reads can be stale; only the claim must be exact |
| Aggressive rate limiting | One request per user per second stops bot amplification |
The waiting room is the single most effective measure
Rather than letting a million people hit the booking system, admit a few thousand at a time and hold the rest on a lightweight page served by a CDN.
Three benefits: the booking system only ever sees load it can handle; users get an honest queue position instead of errors; and the system degrades predictably rather than collapsing.
It also makes the experience fairer. Without it, whoever has the fastest connection and the most aggressive refresh script wins.
7. What Was Traded Away
| Decision | Gained | Cost |
|---|---|---|
| Optimistic locking | No locks held across slow requests | Many retries during contention |
| Unique constraint as backstop | Overselling is structurally impossible | A little write overhead |
| Timed holds | Users can pay without losing the seat | Seats unavailable for up to 10 minutes |
| Expiry checked on read | Correct even if the sweeper fails | Slightly more complex queries |
| Waiting room | System never overloads | Users wait, and know they are waiting |
| Cached availability counts | Browsing stays fast | Counts are slightly stale |
Follow-on Questions
Related Reading
- Consistency and CAP — isolation levels and why read-then-write races
- Payment System — idempotency and sagas
- Rate Limiter — the waiting room and traffic control
- Databases — constraints, locking and sharding