Skip to content

Ticket booking

intermediateCommon atTicketmasterMetaAmazonGoogleStripeBooking.comRead firstConcurrencyTTL and expiryIdempotencyRate limitingPractise in the Sandbox →

A booking system sells a fixed, tiny inventory to a huge, impatient crowd. The invariant is one seat, one buyer. The standard design keeps seats as rows in a transactional database and moves them available → held → sold with conditional updates, so the database, not the app, settles every race. Holds carry an expiry checked at read time, payment runs against a hold that can’t expire mid-charge, and a virtual waiting room admits fans at the rate checkout can absorb. Each booking is O(seats in the order) row writes; the hard part is contention on a few hot rows, not data size.

A stadium has 50,000 seats. At 10:00, two million people want them, and most want the same few hundred near the stage. The system has to pick winners fairly, never sell a seat twice, and stay up.

Goal What it means Pulls toward
No double booking Each seat is sold at most once, even under a stampede Serialised writes per seat, transactions
No phantom sell-outs Seats held by people who walked away come back quickly Short holds, expiry checked at read time
Survive the spike Millions of requests in minutes, for a few minutes Queueing at the edge, admission control
Fairness Bots and refresh-spammers don’t win by default Waiting room, per-user limits, verified fans

The first goal is absolute. The others are trade-offs you tune, mostly through the hold TTL and the admission rate.

In scope (typical) Ask about
Functional Browse events, see a seat map, hold specific seats (or N general-admission tickets), pay, get tickets. Release holds on timeout or cancel. Reserved seating, general admission, or both? Max tickets per order and per person?
Scale Catalogue browsing: steady, cacheable. On-sale: 1-10M fans for 10k-100k seats in minutes. How spiky? How many on-sales at once?
Latency Hold in under 500 ms at p99. Seat map can lag by a second or two. How fresh must the seat map be?
Consistency Strong for seat state: no seat sold twice. Eventual for maps, counts, search. Is overselling ever acceptable (airlines do it on purpose)?
Availability The on-sale must not fall over; degrade by queueing, not by erroring. What happens to queued fans if inventory runs out?
Out of scope Payments internals, resale marketplace, dynamic pricing, ticket transfer, fraud models.
Back of the envelope
drag the assumptions
assumptions
so you need
fans per seat40×most fans will not get a ticket; fairness becomes the product
orders to sell out25K
time to sell out at full capacity2.1 min
refresh load with no waiting room400K/severy fan hammering the seat map and the API
concurrent holds at steady state25Kadmitted rate × hold time, capped by inventory
Two million fans for 50,000 seats is 40 fans per seat. The inventory sells out in a couple of minutes even at a modest checkout rate; the real load is everyone else refreshing. That is the number that forces a waiting room.

What to say out loud: “The data is tiny, 50k seat rows fit anywhere. The write rate a database must handle is bounded by checkouts, a few hundred per second. The danger is the read and retry storm from millions of fans who will mostly lose, so I’ll absorb them at the edge with a waiting room and admit at the rate checkout can sustain.”

pseudocode: the contract
GET /events/{id}/seatmap -> sections, seats with status (cached, ~1-2 s stale)
POST /events/{id}/holds Authorization: <admission token>
{ seats: ["A11","A12"] } or { section: "GA-floor", quantity: 2 }
-> 201 { hold_id, seats, expires_at } | 409 { unavailable: ["A12"], seatmap_version }
POST /holds/{hold_id}/checkout Idempotency-Key: <hold_id> { payment_method }
-> 200 { order_id, tickets } | 410 hold expired | 402 payment declined
DELETE /holds/{hold_id} -- release early (fan changed their mind)
Records:
seat { event, seat_id, section, price_tier, status: available | held | paying | sold,
hold_id?, expires_at?, version } -- PK (event, seat_id)
hold { id, event, user, seat_ids, created_at, expires_at, status }
order { id, hold_id (unique), user, amount, payment_ref, created_at }
ga_inventory { event, section, total, sold, held } -- for general admission

Two decisions hide in that sketch:

  • Seat state lives on the seat row, not only on the hold. That makes “is A12 free?” a single-row read, and lets one conditional update do the check and the claim together. The hold row is the receipt.
  • hold_id is unique on order. It doubles as the payment idempotency key, so a double-clicked Pay or a retried checkout can create at most one order and one charge. See Payment system.
Ticket booking: hold, contention, payment, expiry and on-sale
step through
flow
Reads can be stale; the hold itself is one atomic, conditional write.
seat mapconditional UPDATEinvalidateFan (browser / app): clientFanbrowser / appWaiting room (edge · tokens): serviceWaiting roomedge · tokensBooking API (stateless · ×N): workerBooking APIstateless · ×NSeat map cache (read-mostly): cacheSeat map cacheread-mostlyInventory DB (seats · holds · orders): storeInventory DBseats · holds · ordersPayments (idempotent): externalPaymentsidempotentHold sweeper (expire + release): workerHold sweeperexpire + releaseOrder events (tickets · email): queueOrder eventstickets · email
tokenadmitted, valid 15 min
The fan already has an admission token from the waiting room. The API checks its signature on every call; no token, no booking.
1 / 4
Component Role Why it’s there
Waiting room At the edge: queues fans, issues signed admission tokens at a controlled rate. Converts a spike of millions into a steady stream the backend can serve.
Booking API (×N) Validates tokens, places holds, runs checkout. Stateless; scales out; any node can serve any fan.
Seat map cache Read-mostly availability per event, refreshed on change. 99% of traffic is looking, not buying. Reads never touch the primary.
Inventory DB Seats, holds and orders, partitioned by event. Source of truth. Row-level locks and transactions settle every race.
Payments External or internal service, called with the hold id as idempotency key. Money must move once per hold, however many times Pay is clicked.
Hold sweeper Periodically resets expired holds and refreshes counts. Housekeeping only; correctness already comes from the expiry check.
Order events Queue for ticket issuing, email, analytics. Slow work off the hot path.

Every design here is about one race: two buyers see A1 free, both try to take it. Run the strategies below. Set “arrivals spread” to 0 for a flash sale.

Many buyers, few seats: who gets A1?
simulator
Read availability, wait D ticks (the transaction), write "sold". Nothing stops two buyers writing the same seat.
stage ▼ seats (front rows are the hot ones)
A1
A2
A3
A4
A5
A6
A7
A8
B1
B2
B3
B4
B5
B6
B7
B8
buyers at t = 0
conflicts (none detected: nobody checks)
seats sold: 0 / 16double-booked seats: 0conflicts / retries: 0lock-wait ticks: 0avg / max time to a seat: 0.0 / 0attempts per buyer: 1.00
in a transaction / row lockedwaiting for a lockgot a seat / solddouble-bookedsold out, left
Two buyers who read the same free seat within D ticks both write it, and both are told they won. Spread arrivals out or shorten D and it happens less often, which is why it survives testing and shows up at the on-sale. Right now 0 buyers think they have a seat, for 0 seats actually written.
t = 0 / 60
Strategy Double books? Under contention Best when
Read, then write (no lock) Yes Looks fast; is wrong Never for inventory
Pessimistic: SELECT ... FOR UPDATE, then update No Buyers queue on hot rows; each waiter holds a connection Contention is high and transactions are short
Optimistic: version check on write No No waiting, but losers redo work; retries spike on hot seats Contention is low or moderate
Conditional update (UPDATE ... WHERE status = 'available') No The row lock lasts one statement; losers learn at once The default for holds: atomic, one round trip
Distributed lock (Redis) per seat Only if the lock expires mid-write Adds a system and a failure mode Rarely worth it when the DB already locks rows

The conditional update is optimistic concurrency without a version column: the WHERE clause is the check. At the default READ COMMITTED level, Postgres waits for a concurrent writer and then re-evaluates the condition against the latest row version, so exactly one of two racing updates matches. (Under REPEATABLE READ the loser gets a serialization error to retry instead.) The Concurrency page covers check-then-act races in general.

pseudocode: placing a hold
place_hold(event, seat_ids, user, ttl):
hold_id <- new id
txn:
n <- UPDATE seat
SET status = 'held', hold_id = hold_id, expires_at = now + ttl, version = version + 1
WHERE event = event AND seat_id IN seat_ids
AND (status = 'available' OR (status = 'held' AND expires_at <= now))
if n != len(seat_ids):
ROLLBACK -- all seats or none
return 409 with the seats that are gone
INSERT hold (hold_id, event, user, seat_ids, expires_at = now + ttl)
publish "seatmap changed" (async) ; return 201

Details that come up:

  • All or nothing. A fan who asked for 4 seats together doesn’t want 3. The rows-updated count decides, and the rollback releases the partial claim.
  • Lock order. If you lock several seats with FOR UPDATE, lock them in a fixed order (by seat id), or two buyers taking overlapping seats can deadlock.
  • Never hold a database lock across a payment. A lock that waits on a card network for 3 seconds serialises your on-sale. The hold status is the long-lived reservation; row locks last milliseconds.
  • General admission is the same idea on a counter: UPDATE ga_inventory SET held = held + n WHERE sold + held + n <= total. One row takes every write for the section, so split big sections into several counter rows (buckets) and try another bucket on failure.

A hold is a lease on a seat: you own it until expires_at. Its TTL is a trade-off between buyers who abandon (and lock seats away from others) and real buyers who are slow (and lose seats they were about to pay for). Change the TTL and the reclaim mode.

Holds, abandonment and expiry over time
simulator
seat 1seat 6seat 11seat 16time →
seats sold: 0 / 20turned away while seats sat dead: 0turned away total: 0expired before Pay: 0charged, then refunded: 0dead seat-ticks: 0
held (will pay)held by an abandonerexpired, not yet freepayment in flightsold
The TTL is a trade-off you can see. Long: abandoned holds (amber) sit on seats while buyers are turned away. Short: slow but real buyers hit Pay after expiry, and without the extension some are charged for seats they no longer hold. Checking expires_at at read time makes seats reusable the instant they expire; a sweeper alone leaves red gaps until it runs.
t = 0 / 60

What to take from it:

Choice Too little Too much
Hold TTL Slow buyers lose seats at the payment page Abandoned holds make the show look sold out; fans turned away
Sweeper frequency (if it’s the only reclaim) Dead seats sit unusable between runs Load on the DB for little gain
Payment extension Charged, then the hold is gone: refunds and angry fans A stuck payment pins seats; cap it with the payment timeout

The fix for the sweeper problem is to make expiry part of the read and write conditions (expires_at <= now counts as available, as in the hold pseudocode above). A seat is reusable the instant its hold expires; the sweeper only tidies up. See TTL and expiry.

pseudocode: checkout against a hold
checkout(hold_id, payment_method):
txn: n <- UPDATE seat SET status = 'paying', expires_at = now + payment_timeout + grace
WHERE hold_id = hold_id AND status = 'held' AND expires_at > now
if n == 0: return 410 hold expired -- nothing charged yet: a clean failure
result <- payments.charge(amount, idempotency_key = hold_id)
if result is declined: release the hold (or return it to 'held' for one retry); return 402
txn: n <- UPDATE seat SET status = 'sold' WHERE hold_id = hold_id AND status = 'paying'
INSERT order (hold_id unique, ...) -- a retry hits the unique key, not a 2nd order
if n == 0: refund(result) and alert -- should not happen; never sell a lost seat

Clocks matter here. Use the database’s now() for every expiry comparison, so app servers with skewed clocks can’t disagree about whether a hold is alive. Show the fan a countdown a few seconds shorter than the real expiry.

Deep dive: the flash sale and the waiting room

Section titled “Deep dive: the flash sale and the waiting room”

The database can take a few hundred to a few thousand checkouts per second. Two million fans arriving at once would overwhelm everything in front of it first: load balancers, API nodes, the seat map. The answer is to queue before the system, not inside it.

  1. Pre-queue. Fans who arrive before the on-sale wait on a static page served from a CDN. At the start time, they are put in random order, so arriving at 9:58 isn’t worse than 9:59 and refresh-spam doesn’t help. Later arrivals queue in arrival order.
  2. Signed tokens. Each fan gets a token with their position, signed by the waiting room. Progress polling hits the edge, not the backend. Cloudflare’s Waiting Room, for example, runs on its edge and lets you configure total active users and new users per minute.
  3. Admission control. The room admits fans at a rate the booking path can sustain, and adjusts it from live signals: checkout error rate, latency, DB load. This is rate limiting applied to people.
  4. Admitted sessions expire. An admission token is valid for, say, 15 minutes and one event, and the booking API checks it on every call. Per-account ticket limits stop one token buying the venue.
  5. Close the queue early. When inventory (including active holds) is gone, tell everyone still waiting. Released holds can go to a waitlist.

Hot partitions. Partition seat rows by event, so one event’s seats live together and a hold is a single-partition transaction. A mega on-sale makes that one partition hot. Spread it by section: each section is independent (a hold never spans sections in practice, or you restrict it to), so sections can live on different shards and be served in parallel.

Seat map freshness. Recompute the map from change events and push deltas, or serve it from a cache with a 1-2 s TTL. A stale map causes some 409s, which is acceptable; the write path is always authoritative. Show “best available” seat selection as an option, so most fans skip the map entirely and the server picks seats with fewer conflicts.

What breaks What you see Design response
Two holds race for a seat One gets 0 rows updated Conditional update; 409 with fresh availability
Fan abandons Seats held, nobody pays Short TTL; expiry checked in the hold condition; sweeper tidies
Payment slower than the hold Charged for a seat you no longer have Move to paying and extend before charging; refund if confirm fails
Double-click on Pay Two checkout requests hold_id as idempotency key and unique on order
On-sale spike Edge and API saturate Waiting room with admission rate tied to checkout capacity
Hot event partition One shard at 100% Split by section; best-available assignment spreads writes
Bots Inventory gone in seconds to scalpers Verified-fan presale, CAPTCHA at queue entry, per-account limits
DB primary failover Holds fail for seconds Clients retry holds (idempotent by hold id); queue pauses admission
App server clock skew Holds appear alive or dead inconsistently Compare against the database’s clock only
Operation Time Space
Seat map read O(1) from cache; O(seats) to rebuild for one event ≈50 B × seats per event (50k seats ≈ 2.5 MB)
Place a hold O(k) row updates for k seats, one transaction, ~1-5 ms one hold row + k seat updates
GA hold O(1) counter update one row per section bucket
Checkout 2 short transactions + 1 payment call (≈1-3 s) one order row, k tickets
Expire holds O(1) per check at read time; sweeper O(expired) with an index on expires_at index on (status, expires_at)
Waiting room O(1) per poll at the edge one signed token per fan, no server state needed
Throughput bound ≈ checkouts/s the DB sustains (hundreds to low thousands per partition) —
  1. Clarify (5 min). Reserved seats or GA, tickets per order, scale of the biggest on-sale, overselling never allowed, seat map freshness.
  2. Data model and the invariant (5 min). Seat rows with status, hold id, expiry, version. State the invariant out loud: one seat, one buyer.
  3. Hold and checkout (15 min). Conditional update for holds, all-or-nothing, 409 on conflict. Checkout with a paying state and hold id as idempotency key. Compare pessimistic and optimistic locking when asked.
  4. Expiry (5 min). TTL trade-off, expiry in the condition, the sweeper as housekeeping, what happens if payment outlives the hold.
  5. The on-sale (10 min). Waiting room, randomised pre-queue, admission rate tied to capacity, bots, partitioning by event and section.

Follow-ups to expect: “Why not Redis locks for holds?” The database already serialises row writes; a second system adds a lock that can expire mid-write unless you add fencing tokens. Redis can hold soft holds for GA at extreme scale, with the DB as the final check. “A seat shows free but the hold fails.” Expected: the map is a cache; return fresh availability with the 409. “How do you make the queue fair?” Randomise early arrivals, one position per verified account, and don’t let refreshes create new positions. “Overbooking like airlines?” That’s a business choice: sell slightly beyond capacity on a counter and compensate; not for numbered seats.

Check yourself
pick one
Two API nodes run `UPDATE seat SET status='held', hold_id=? WHERE seat_id='A12' AND status='available'` within 2 ms of each other. What happens?
Check yourself
pick one
Holds are freed only by a sweeper that runs every 5 minutes, with a 10-minute TTL. Fans report 'sold out' while the venue later shows empty seats. What's the cheapest correct fix?
Check yourself
pick one
A fan clicks Pay with 20 seconds left on the hold. The card network takes 30 seconds. What should the design guarantee?

Concept pages to drill: Concurrency · TTL and expiry · Versioning · Idempotency · Rate limiting · Resource allocation · State machine