DSA Guide
System Design

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

QuestionAssumption 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 : 1

The 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 immediately

Booking 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 clearsRows locked for seconds, during a 17,000/sec surge
Charge first, assign the seat afterSometimes 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

ApproachHowTrade-off
Pessimistic lockSELECT … FOR UPDATE — lock the row, then writeCorrect; locks held across a slow request
Optimistic lockConditional update; retry if it affects 0 rowsCorrect; no locks; retries under contention
Unique constraintA 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.

The seat lifecycle
select seatconditional updateUserBooking ServiceSeatsstate + held_untilPaymentExpiry Sweeper
ClientServiceDatabaseExternal
stateavailable → heldexpires10 min
One atomic statement moves the seat to held and stamps an expiry. If it affects no rows, someone else got there first.
1 / 3
A hold is a lock with an expiry, enforced in the data rather than in memory.

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.

TechniqueEffect
Virtual waiting roomAdmit users in controlled batches; everyone else waits on a static page
Pre-scaleOn-sale times are known in advance — scale up beforehand
Static everythingEvent pages, images and seat maps come from a CDN
Queue the browsing, not the buyingReads can be stale; only the claim must be exact
Aggressive rate limitingOne 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

DecisionGainedCost
Optimistic lockingNo locks held across slow requestsMany retries during contention
Unique constraint as backstopOverselling is structurally impossibleA little write overhead
Timed holdsUsers can pay without losing the seatSeats unavailable for up to 10 minutes
Expiry checked on readCorrect even if the sweeper failsSlightly more complex queries
Waiting roomSystem never overloadsUsers wait, and know they are waiting
Cached availability countsBrowsing stays fastCounts are slightly stale

Follow-on Questions

On this page