Designing a Ticket Booking System
In a scarce-inventory system, showing a seat as available and actually guaranteeing it's available are two different problems — the architecture has to serve fast reads for browsing and correct atomic writes for reservation at the same time, without ever losing track of who actually holds a seat during payment ambiguity or an expiring hold.
Problem Definition
We are designing a ticket booking system where many users compete for a limited number of seats. The system must never sell the same seat to two users, regardless of how many concurrent requests it receives.
At low load, this is straightforward. At high load — a popular concert, championship match, or major event going on sale — the system can receive far more requests than seats available. The vast majority of users will fail to get a seat. The architecture must handle that gracefully while preserving correctness for those who succeed.
This same no-double-sell requirement underlies concert and live-event ticketing (Ticketmaster-style platforms), movie ticket booking (assigned-seat cinema bookings), and stadium/sports seat reservation — any product where a fixed, identifiable set of seats must be sold exactly once under a demand spike.
Two inventory models
Ticket booking systems generally serve two different inventory models, and it's worth naming both up front rather than assuming every event has assigned seats:
Assigned-seat inventory
Each seat is a distinct, identifiable unit (row A seat 12) with its own state. Reservation means acquiring one specific unit, and the seat map itself is part of the product experience.
General-admission inventory
There's no specific seat identity — just a capacity count for a section or the whole event (available/reserved/sold). Reservation means decrementing a counter by N under the same concurrency-safety requirements, without any per-seat identity to reason about.
The correctness problem is the same in both models — never oversell scarce capacity — but the mechanics differ: assigned-seat reservation is a per-row atomic transition, while general-admission reservation is an atomic bounded-decrement. This module focuses mainly on assigned seats since they're the harder case, but calls out where general admission simplifies or changes the picture (see General-Admission Inventory in Architecture Deep Dive).
The central distinction
Seat availability shown to users does not need to be perfectly real-time. A seat map that is a few seconds stale is fine for browsing. But the actual reservation operation must always validate the authoritative seat state. These two requirements — fast browsing and correct reservation — require different architectural approaches.
Visual Design Journey
Watch one ticket-booking system evolve from a naive seat lookup that double-sells seats into an atomic reservation architecture with holds, expiry safety, and admission control for high-demand sales.
Requirements
Functional requirements define what users can do. Non-functional requirements define the correctness and scale constraints that shape the architecture.
Functional
Browse events. View seat maps. Select seats (possibly more than one at a time). Temporarily hold selected seats. Complete payment during the hold window. Receive booking confirmation and a ticket. View booking history. Cancel a booking where policy allows it.
Non-functional
No double-selling
The same seat must never be successfully confirmed for two different bookings. This is the #1 correctness invariant. No performance trade-off justifies violating it.
Idempotent booking submission
Retried requests — from client timeouts, double-clicks, or network retries — must not create duplicate holds or duplicate bookings. This matters as much as the no-double-sell invariant; a system that avoids overselling but still lets a retry create a phantom second booking is still broken.
Bounded hold lifecycle
Every hold must resolve — to a confirmed booking or back to available inventory — within a bounded time, even if the worker responsible for expiry crashes, is delayed, or runs multiple instances concurrently.
Safe payment ambiguity handling
When payment success/failure is unknown or arrives late relative to a hold's lifecycle, the system must have an explicit, defined policy — never silently guess, and never let two different resolutions apply to the same seat.
Fairness and admission under load
When demand vastly exceeds capacity, the system needs a defined way to decide who gets to attempt a reservation and when — not just an unmanaged race to the booking endpoint.
Recoverability and auditability
Every booking's history — hold created, payment attempted, confirmed or expired — should be reconstructable after the fact, for both customer support and reconciliation.
Low-latency availability reads
Seat maps must load fast under an explicit freshness target. Users browsing events cannot wait seconds for each availability refresh. Caching is appropriate here.
Traffic spikes
High-demand events can see traffic far above their normal baseline within seconds — the exact multiple varies by event and is illustrative, not a fixed constant. The architecture must handle this without collapsing.
Seat Inventory
An event has a venue with seats. Each seat has a state. Start simple: AVAILABLE or BOOKED.
Unlike bulk product inventory (where you stock many interchangeable units of the same SKU), a seat is unique. Seat A1 is different from seat A2 — different location, view, possibly different price. The identity of each seat matters to the buyer, not just its count.
The Race Condition
One seat remains. Two users select it simultaneously. A naive implementation allows both to succeed — double booking.
The problem is that read → decide → write is not a single atomic operation. User A reads AVAILABLE. User B reads AVAILABLE. Both decide to book. Both write BOOKED. The seat is now marked as sold to two people.
Application-level checks alone ('if status is available, then write booked') can never fully close this gap, because the check-then-act sequence itself isn't atomic no matter how carefully it's written in application code. Real protection comes from backing the transition with a database-level constraint or conditional atomic operation — see Atomic Reservation next — so that even a buggy or duplicated application-layer call can't produce two winners.
Atomic Reservation
The fix: make the reservation a single atomic operation that can only succeed for one request, and make it idempotent so retries can't create a second one.
Instead of separate read and write operations, perform a conditional atomic update: transition the seat from AVAILABLE to HELD only if the current state is AVAILABLE. This operation cannot be interleaved with another request's operation on the same seat.
Conditional database update
UPDATE seats SET state = 'HELD' WHERE id = ? AND state = 'AVAILABLE'. If rows_affected = 0, the reservation failed — someone else already holds or owns the seat.
Optimistic concurrency control
Read seat with a version number. Write with a version check. If the version changed, the write is rejected. Retrying only helps when the conflict was transient (someone else's write already resolved and the seat is genuinely still available under a new version) — retrying a straight ownership conflict on the same unique seat wastes a request, since that seat is simply gone.
Row-level locking (SELECT FOR UPDATE)
Lock the row only for the duration of the short transaction that creates or updates the hold record — never across the customer's payment window. Holding a database row lock while waiting minutes for a customer to pay would tie up connections and serialize unrelated requests; the lock's job is to make the hold-creation transition atomic, not to reserve the row for the whole checkout.
Booking and hold idempotency
A conditional atomic update prevents two different users from winning the same seat — but it does not, by itself, prevent one user's retried request from being treated as a second, independent attempt. A client that times out and retries, or a double-click, must resolve against the same underlying hold rather than attempt a new reservation. The standard mechanism is a client-supplied idempotency key stored durably alongside the hold, so a retry with the same key returns the original result instead of re-running the reservation logic. See Architecture Deep Dive -> Hold Idempotency for the full mechanism.
Multi-seat reservation
Booking more than one seat together adds a requirement beyond single-seat atomicity: all requested seats must succeed together, or (absent an explicit partial-hold feature) none should. A common failure mode is acquiring seats one at a time and only detecting a conflict on the last one, after already holding the others. The fix is to attempt all seats within a single transaction and verify all succeeded before committing — combined with a deterministic lock/acquisition order (for example, sorted by seat ID) across every request, so that two concurrent multi-seat requests touching overlapping seats can't deadlock each other. See Architecture Deep Dive -> Multi-Seat Reservation.
Temporary Seat Hold
A seat hold gives the user time to complete payment without the seat being taken by someone else. It is not a completed booking.
Without holds, users would need to complete payment in a single instant — or race against other buyers during payment. A hold reserves the seat for a bounded window, giving the user time to pay while preventing others from taking the seat.
A hold is a durable record with its own identity — not just a flag on the seat row. At minimum it needs a hold_id, the owning user/session, the seat(s) it covers, a created_at, an expires_at, and a version. That identity is what lets the system tell 'this specific hold' apart from 'a seat that happens to be marked HELD' — a distinction that turns out to matter enormously once expiry is in the picture (see Hold Expiration).
Hold duration is a product decision
How long a hold lasts is a trade-off between giving legitimate buyers enough time to pay and not locking scarce inventory away from other buyers for too long. There's no single correct duration — a high-demand on-sale might use a short window; a low-contention event might use a longer one. Treat any specific number as an illustrative example, not a rule.
Hold expiration
A hold that is never paid must eventually release the seat. Without expiration, every abandoned checkout permanently locks a seat — the event becomes falsely sold out.
Hold Expiration
Abandoned holds must be automatically released. Expiration is itself a concurrency problem — and the way most designs get it subtly wrong is worth walking through carefully.
An expiration mechanism identifies holds that have passed their expiry timestamp and transitions them back to AVAILABLE. But what if the user pays at the exact moment the hold expires? Or what if the release task itself is delayed and runs late, after the seat has already moved on?
Naive expiration is unsafe
A release that only checks 'is this seat's state HELD' is not safe. If the original hold expired, the seat was released, and a second user acquired a new hold on the same seat, a delayed release task from the first hold would see state = HELD (now belonging to the second user) and incorrectly release it — stealing the seat out from under its new, legitimate owner.
The correct condition: scope release to hold identity
The release operation must be conditioned on the specific hold's identity (and/or version), not just the seat's current state: release only if this seat's current hold_id still equals this specific hold's ID. If another hold has since taken the seat, the hold_id will differ and the conditional release safely does nothing. This is the single most important correctness detail in hold expiry — see Architecture Deep Dive -> Expiry Generation Protection for the exact mechanics.
Expiration vs. payment race
Confirmation (HELD → CONFIRMED) and expiration (HELD → AVAILABLE) both need to be conditioned on the same hold's current identity and state, not merely 'is the seat HELD.' Whichever conditional transition commits first wins; the other's check simply fails and does nothing. Late-arriving payment success after expiry already won is a real scenario with its own recovery policy — see Payment/Hold Boundary in Architecture Deep Dive, and Failure & Scale Scenarios -> Payment Success After Hold Expiry.
Expiration mechanism options
A scheduled scan for passed expiry timestamps, a database-native TTL feature, a delayed job/timer queue, or an event-driven timer are all viable mechanisms. The mechanism varies — the requirement is identical: expired holds must eventually release the seat safely, and the release must remain correct even if duplicated, delayed, or executed by more than one worker at once. See Architecture Deep Dive -> Expiry Worker Design.
Booking State Machine
A booking is a lifecycle, not a boolean. Explicit states represent what actually happened — and it helps to keep the hold, the booking, and the payment as related but distinct state machines.
SELECTED → HELD (atomic reservation) → PAYMENT_PENDING → CONFIRMED. Failure path: HELD → EXPIRED → seat returns to AVAILABLE. Payment failure: PAYMENT_PENDING → FAILED → seat returns to AVAILABLE.
Three related state machines, not one
The hold has its own lifecycle (ACTIVE → CONFIRMED or EXPIRED). The booking has its own lifecycle (PENDING → PAYMENT_PENDING → CONFIRMED, or CANCELLED). Payment has its own lifecycle, owned by the payment integration (PENDING → SUCCEEDED or FAILED). Treating all three as one combined flag makes it much harder to reason about the exact ambiguous states discussed in Payment/Hold Boundary — where the hold has expired but payment is still unresolved, for example.
Availability vs Reservation
The central architectural distinction: availability reads and booking writes are two different problems requiring different solutions.
Availability reads
Users refresh seat maps constantly. These are read-heavy, highly cacheable, and tolerate slight staleness against an explicit freshness target — see Trade-offs & Decisions -> Availability Cache Strategy for how to choose that target and the caching approach that fits it.
Reservation writes
When a user actually reserves a seat, the operation must go to authoritative state. No cache. No shortcut. The cached availability view has no bearing on reservation correctness.
Staleness cuts both ways
A cached seat map can show a seat as available when it was just taken (a stale positive — the reservation attempt correctly fails against authoritative state) or show a seat as unavailable when its hold just expired and it's actually free again (a stale negative — a missed opportunity, not a correctness bug). Both are UX costs bounded by the cache's freshness policy, not correctness failures, as long as reservation never trusts the cache.
High-Demand Events
A major event goes on sale. Traffic arrives far above baseline within seconds. The architecture must handle this without collapsing — and 'traffic' here is really four different kinds of load, each with its own bottleneck.
Browse load
The vast majority of high-demand traffic is availability reads — users refreshing the seat map waiting for a seat to appear. This is absorbed by the availability cache/read model and barely touches the authoritative database if caching is designed well.
Admission load
A smaller slice of users actively attempt to enter the booking flow. If everyone is allowed to hit the reservation path at once, the authoritative database can be overwhelmed even though only a tiny fraction of those requests could ever succeed. A virtual waiting room or admission controller paces entry into the booking flow itself — see Architecture Deep Dive -> Waiting Room.
Reservation load
Of admitted users, a fraction attempt an actual atomic seat reservation. This is the correctness-critical, authoritative-database path — the one that must never be starved or overwhelmed, since it's what actually protects the no-double-sell invariant.
Payment load
Of successful reservations, most proceed to payment. This load hits the payment integration and reconciliation path, and is bounded by however many holds were actually granted — a much smaller number than the original browse traffic.
Hot single-event traffic
Sharding purely by event_id isolates one event's load from another's, but it does not help when the load is concentrated on a single blockbuster event — the whole event still lands on one shard. Scaling a single hot event requires finer partitioning (event -> section/block -> seat) or admission control specific to that event, at the cost of harder cross-partition coordination for multi-seat requests. See Architecture Deep Dive -> High-Demand Partitioning.
Worked example: one on-sale (illustrative)
These are illustrative planning numbers, not measurements of any real system or event. Assumptions: an arena with 20,000 assigned seats; 1,000,000 users on the seat map at on-sale; each refreshes availability every 5 s; the availability cache or read model serves 99% of those reads; the reservation path for this event sustains about 1,000 hold transactions/s (an assumed figure for one event's partition); an average party of 2 seats; a hold TTL of 10 minutes; each hold writes about 3 rows (two seat rows and the hold); and 70% of holds convert to paid bookings.
| Load | Formula | Result |
|---|---|---|
| Browse reads | 1,000,000 ÷ 5 s | 200,000 requests/s |
| Browse reads reaching the origin | 200,000 × (1 − 0.99) | ≈ 2,000 requests/s |
| Admission rate (waiting room) | sized to the reservation ceiling | ≈ 1,000 users/s admitted |
| Maximum successful holds | 20,000 seats ÷ 2 seats per hold | 10,000 holds |
| Time to sell out at the admission rate | 10,000 holds ÷ 1,000 attempts/s | ≈ 10 s of admission (longer with conflicts and retries) |
| Reservation database writes | 1,000 × 3 rows | ≈ 3,000 rows/s |
| Payments | 10,000 holds × 70% ÷ 600 s hold window | ≈ 7,000 payments, ≈ 12/s on average |
| Hold-expiry wave | 10,000 × 30% unpaid holds, released together at TTL | 3,000 holds (6,000 seats); at 500 hold releases/s ≈ 6 s |
What this shows: the four loads differ by orders of magnitude, about 200,000/s of browse, 1,000/s of reservation attempts and roughly 12/s of payments, so one capacity number would be wrong for all of them. Browse is solved by caching, not by the database; the reservation path is the correctness-critical ceiling and the reason the waiting room paces admission to it; and the event sells out within seconds of admission, after which the next burst is the expiry wave returning seats to inventory, which is why expiry must be as safe and idempotent as reservation. Adding application servers changes none of these constraints.
Final Architecture
Every component earns its place by solving a specific demonstrated problem. Not every component below is mandatory for every scale of system — a small event without hot-demand on-sales may not need a waiting room at all.
- 01API Gateway: routing, auth, rate limiting
- 02Waiting Room / Admission Controller: paces entry into the booking flow during high-demand on-sales — optional at low scale
- 03Availability Service + Cache: seat map browsing, eventually consistent, fast
- 04Booking Service: reservation orchestration, atomic seat/hold state transition, idempotency handling
- 05Booking Database: authoritative seat, hold, and booking state — ACID
- 06Payment Service: idempotent payment, coordinated with booking state via an explicit payment/hold boundary policy
- 07Expiry Worker: scheduled, identity-scoped release of abandoned holds
- 08Transactional Outbox + Relay: guarantees booking-confirmed events are eventually published even across a crash
- 09Reconciliation Worker: resolves payment/booking ambiguity that the normal flow couldn't resolve in time
- 10Ticket Issuance Service: idempotent generation of the customer-facing ticket/entitlement
- 11Notification Service: confirmation delivery