Skip to content
SeeItFlow
System Design · Architecture Deep Dive · ~26 min read

Ticket Booking Architecture Deep Dive

A layer-by-layer breakdown of every mechanism a production ticket booking system needs -- the event/seat model, the availability read model, a first-class durable hold entity, atomic and multi-seat reservation, concurrency control, hold idempotency and expiry, the booking state machine, the payment/hold boundary, the transactional outbox, idempotent ticket issuance, reconciliation, cancellation/refund, the virtual waiting room, hot-event partitioning and observability.

Mangesh MandlikLast updated:
Deep Dive 01

Event & Seat Model

An event, a venue seat, and an event-specific sellable seat are three related but distinct concepts -- collapsing them makes it hard to sell the same physical seat across multiple events.

An Event carries the venue/show reference, start time, sale status, sale window, and inventory mode (assigned seating vs. general admission). A physical venue seat (section/row/number) is a stable fixture of the venue; the event-specific seat is the sellable unit for one particular event, carrying its own allocation state, pricing zone, and lifecycle -- the same physical seat is sold independently for each event held in that venue.

EntityKey fieldsScope
Eventevent_id, venue/show reference, start time, sale status, sale window, inventory modeOne row per scheduled show/match/screening
Seat (event-specific)seat_id, event_id, section/row/number, pricing zone, allocation state or allocation referenceOne row per sellable seat, per event
Deep Dive 02

Availability Read Model

The seat map a user browses is a read model -- a cache, a read replica, or a materialized view -- deliberately kept separate from the authoritative reservation path.

Seat-map reads are scoped per event (and often per section, for large venues) so a cache key naturally isolates one event's traffic from another's. The read model can be a cache in front of the booking database, a dedicated read replica, or a periodically rebuilt materialized snapshot -- read replicas are one option among these, not a mandatory component.

Stale positive

The read model shows a seat as available when it was actually just held or confirmed by someone else. The user attempts a reservation and is correctly rejected by the authoritative path -- a conversion-rate cost, not a correctness failure.

Stale negative

The read model still shows a seat as held or sold when a hold has actually just expired and released it back to availability. The user doesn't see the seat as bookable until the read model catches up -- an availability-display lag, not an allocation error either way.

Deep Dive 03

Hold Data Model

A hold needs a durable identity of its own -- it is not simply a flag on the seat row. Without an identity, the system can't tell an old, delayed cleanup task apart from a legitimate new owner.

FieldPurpose
hold_idDurable identity for this specific hold attempt
event_idWhich event this hold belongs to
user/session_idWho requested it
seat_id(s)One or more seats covered by this hold
stateACTIVE, CONFIRMED, EXPIRED, or RELEASED
created_at / expires_atBusiness validity window
versionGuards concurrent transitions on this hold
idempotency/request identityMaps repeated client requests to the same logical hold -- see Hold Idempotency below

The hold is neither the payment nor the final ticket -- it's the durable record that a specific seat (or seats) is provisionally owned by a specific request, for a bounded time, pending payment resolution.

Deep Dive 04

Atomic Seat Reservation

The core invariant: at most one live allocation -- an active hold or a confirmed booking -- can own a given seat for a given event at any time.

Swipe to pan
Atomic Reservation — One SucceedsConditional update: AVAILABLE → HELD · only one can succeedUser AUser BBooking SvcBooking DBA1A1HELD ✓ (User A)still AVAILABLEREJECTED ✗ (User B)atomic: WHERE state='AVAILABLE'
Conditional update: AVAILABLE -> HELD -- only one concurrent request can succeed
sqlA conditional update is a strong baseline for the single-seat case
UPDATE seats
SET state = 'HELD', hold_id = :hold_id, held_until = :expires_at, version = version + 1
WHERE seat_id = :seat_id AND event_id = :event_id
  AND state = 'AVAILABLE';
-- rows_affected = 0 means another request already won this seat
Deep Dive 05

Multi-Seat Reservation

A user requesting A1, A2 and A3 together usually expects all-or-none -- and that semantic has to be enforced by the reservation operation itself, not assumed.

When all requested seats live in one transactional partition (one database, one shard), the baseline is: acquire or conditionally update every requested seat's row inside a single transaction, in a deterministic order (e.g. sorted by seat_id), verify every seat succeeded, and commit only if all did -- rolling back the whole attempt if any single seat fails.

Deterministic lock ordering

Always acquiring seats in the same sorted order (rather than the order the user clicked them) prevents two concurrent multi-seat requests from deadlocking on each other's rows.

Partial acquisition is an explicit feature, not a default

If A2 is already held by someone else, the transaction must not silently leave A1 and A3 held while A2 fails -- that violates the all-or-none contract most booking products promise. Offering partial holds ("we got you 3 of the 4 you wanted") is a legitimate product choice, but it must be deliberate, not an accidental side effect of a partially-completed transaction.

Deep Dive 06

Optimistic vs. Pessimistic Concurrency

Three mechanisms enforce the same reservation invariant -- the differences are about contention level and how long anything is held, not about which one is 'correct.'

MechanismHow it worksNotes
Conditional updateUPDATE ... WHERE state = 'AVAILABLE' in one statementSimple, strong baseline for the ordinary single-seat case
Optimistic versionRead with a version number, write WHERE version = expected, retry on conflictRetrying only makes sense for a transient version conflict where the operation can still validly succeed -- not after a legitimate ownership conflict on a unique seat
SELECT ... FOR UPDATE (row lock)Lock the row for the duration of one short transactionThe lock must be held only for that brief transaction that creates/updates the durable hold state -- never for the customer's payment window
Deep Dive 07

Hold Idempotency

A client can double-click Reserve, retry after a timeout, or refresh mid-request -- a stable idempotency key is what keeps these from creating a second hold.

A client-generated (or server-issued at seat-selection time) idempotency key maps to a durable uniqueness constraint. The first request creates the hold and stores a request fingerprint (event_id, requested seat IDs) alongside the key; a retried request with the same key resolves to the existing hold rather than attempting a second reservation.

Unsafe key reuse

If the same idempotency key arrives with a different set of requested seats than the original, that's a conflict, not a legitimate retry -- reject it explicitly rather than silently acting on the new seat list or silently returning the old hold.

Deep Dive 08

Hold Expiry

An abandoned hold must reliably release its seats -- but the naive version of this check is unsafe, because it can release the wrong hold entirely.

Hold duration itself is a product decision -- shaped by checkout UX, payment method latency, event demand, and inventory scarcity. Minutes are common, but the architecture must not depend on any one universal duration.

Client countdown is UX only

A countdown timer shown to the user is a UX affordance, not the source of truth. The client's clock is never trusted for the actual expiry decision -- server-side expires_at and authoritative state govern it.

Deep Dive 09

Expiry Generation Protection

Checking only the seat's state during expiry is not enough -- a delayed expiry task for an old hold can release a seat that now belongs to someone else entirely.

Concrete failure sequence: (1) Hold H1 owns seat A1 until 10:00. (2) H1 expires. (3) A1 becomes AVAILABLE. (4) User B acquires a new hold H2 on A1. (5) A delayed expiry task for H1 finally runs. (6) If that task's condition is only 'release A1 if state = HELD', it incorrectly releases H2 -- a hold that hasn't expired at all.

sqlThe condition must include the specific hold identity, not just the seat's current state
UPDATE seats
SET state = 'AVAILABLE', hold_id = NULL, held_until = NULL
WHERE seat_id = :seat_id
  AND hold_id = :h1_id
  AND state = 'HELD'
  AND held_until <= :now;
-- rows_affected = 0 means this specific hold no longer owns the seat -- do nothing
Deep Dive 10

Expiry Worker Design

Expiry at scale is a real worker system, not a one-line cron query -- it needs indexing, batching, and safe concurrency across multiple worker instances.

ConcernApproach
Finding expired holds efficientlyAn index on (state, expires_at) (or an equivalent) rather than a full scan of every seat/hold row
Bounded batchesProcess expired holds in bounded batches, not one unbounded query across the whole table
Multiple workersPartition work (by event, by hold-ID range, or via a claim/skip-locked pattern) so workers don't repeatedly collide on the same rows
Conditional releaseEvery release uses the hold-identity-scoped conditional update from Expiry Generation Protection above
Duplicate-safeA hold already RELEASED/EXPIRED simply produces rows_affected = 0 on a repeat attempt -- a safe no-op
ObservabilityTrack expiry lag directly -- see Ticket Booking Observability below
Deep Dive 11

Booking State Machine

Hold state, booking state, and payment state are three separate, bounded state machines -- forcing them into one giant enum produces invalid combinations.

Swipe to pan
Booking State MachineExplicit states — not booked=true/falseSELECTEDHELDPAYMENT ↑PENDINGCONFIRMEDEXPIREDFAILEDAVAILABLEseat released
Separate but coordinated state machines: hold, booking, and payment
MachineStates
HoldACTIVE -> CONFIRMED, or ACTIVE -> EXPIRED / RELEASED
BookingCREATED -> PAYMENT_PENDING -> CONFIRMED, or -> FAILED / CANCELLED
Payment (owned by Payment System)PENDING/UNKNOWN -> SUCCEEDED / FAILED
Deep Dive 12

Payment Coordination

Payment is a separate bounded context -- this module owns the booking/hold-side coordination around it, not payment internals.

Three outcomes matter to the booking side: known success, known failure, and unknown/timeout. A known failure fails the booking attempt and releases the hold. A known success confirms the booking, conditional on the hold still being valid (see Payment / Hold Boundary below). An unknown/timeout outcome does neither automatically.

Deep Dive 13

Payment / Hold Boundary

A finite hold cannot be guaranteed to outlive every possible source of payment ambiguity -- provider outages, webhook delay, network partitions, or a reconciliation backlog can all outlast any hold duration you pick. The system needs an explicit policy, not a hope that the TTL is 'long enough.'

Baseline policy: (1) while the hold is valid and payment is unresolved, keep the booking PAYMENT_PENDING; (2) reconciliation resolves the provider's actual state; (3) if payment resolves to success while the hold still validly owns the seats, confirm atomically; (4) if the hold has already expired, released, or been reallocated to someone else by the time payment resolves, do not steal the seat back from whoever now holds it; (5) treat that specific case as an exception requiring void/refund or another policy-specific recovery; (6) reconciliation continues until the financial and booking states agree.

See Failure & Scale -> Payment Success After Hold Expiry and -> Payment Success, Seat Reallocated for the two concrete variants of this scenario and their recovery paths.

Deep Dive 14

Confirmation Transaction

Where possible, the booking confirmation, the hold/seat state transition, and the outbox event are written together, inside one authoritative transactional boundary.

When booking, hold, and seat data share one transactional database, this is a single local transaction: booking -> CONFIRMED, hold -> CONFIRMED, seat allocation finalized, and an outbox row inserted -- all committed together or not at all. If this data is split across separate stores (for example, holds and payment state owned by different services), the same guarantee has to be achieved through a saga with its own compensations and a reconciliation backstop, rather than assumed.

Deep Dive 15

Transactional Outbox

Committing CONFIRMED to the booking database and publishing booking.confirmed to a broker are two separate operations -- and a crash between them silently drops the event unless that gap is explicitly closed.

Failure window: (1) the booking becomes CONFIRMED in the database; (2) the process crashes; (3) booking.confirmed is never published, so ticket issuance and notification never fire. The fix: write the booking state transition and a durable outbox row in the same database transaction; a separate relay publishes outbox rows to the broker and marks them published once acknowledged.

Deep Dive 16

Ticket Issuance

A confirmed booking produces one or more ticket artifacts/entitlements -- and issuance has to be idempotent, because the event that triggers it is delivered at-least-once.

The ticket issuance consumer reacts to booking.confirmed and generates the entitlement(s). A stable business idempotency key -- conceptually UNIQUE(booking_id, seat_id) or UNIQUE(booking_id, ticket_sequence) -- ensures a duplicate delivery of the same event recognizes the already-issued ticket and returns/no-ops rather than minting a second, independently usable one.

Deep Dive 17

Booking Reconciliation

Reconciliation here covers more ground than payment lookups alone -- it's the sweep that catches every gap retries and holds' own expiry don't close.

DiscrepancyWhat it looks like
Paid but still pendingPayment succeeded; booking still PAYMENT_PENDING
Paid but seat no longer ownedPayment succeeded; the hold backing it has since expired/reallocated
Confirmed without expected payment evidenceBooking CONFIRMED; no corresponding successful payment on record
Active hold past expiryA hold row still ACTIVE well past its expires_at -- expiry worker lag or failure
Seat HELD, hold record missing/terminalSeat allocation state and its backing hold record disagree
Confirmed booking, no ticketbooking.confirmed fired but ticket issuance never completed
Stuck outbox rowAn outbox event written but never marked published past normal relay lag
Cancellation without resolved refundBooking CANCELLED; refund still pending or missing

Reconciliation properties

Idempotent (a re-run is a no-op for already-repaired cases), safe to rerun, observable, bounded/batched, and able to route ambiguous or financially sensitive cases to manual review rather than auto-repairing every mismatch.

Deep Dive 18

Cancellation & Refund

Cancellation is its own short workflow, and it is not the same fact as a successful refund.

Baseline: validate the booking is still eligible for cancellation per product policy; transition the booking to a cancelling/cancelled state; invalidate or revoke the issued ticket where the product requires that (e.g. admission-scanning use cases); release the underlying inventory for resale if the product allows it; and initiate a refund.

Deep Dive 19

Virtual Waiting Room

A waiting room admits users into the booking path at a bounded rate before they can even attempt a reservation -- it does not itself reserve anything.

PropertyDetail
Admission tokenA signed/opaque token proving the holder was admitted -- not a seat hold
Bounded admission rateSized to the slowest correctness-critical dependency downstream (see High-Demand Partitioning)
Queue positionMay be approximate under extreme load -- an estimate, not a guaranteed slot number
Token expiryTokens are time-bounded; an expired token must be re-admitted, not silently reused
Reconnect/retryThe room has to tolerate users refreshing or reconnecting without granting them a second admission slot
Sold-out terminationOnce inventory is genuinely exhausted, the room should stop admitting and communicate that clearly instead of admitting users into a booking path with nothing left to book
Deep Dive 20

High-Demand Partitioning

Sharding by event_id isolates ordinary events from each other well -- but it does not, by itself, scale one blockbuster event, because that single event still lands entirely on one shard.

Swipe to pan
High-Demand EventCache absorbs read spike · admission control protects booking pathTraffic spikeon-saleCachecache-asideAdmission Ctrlrate limiterAvail. SvcBooking SvcBooking DBACIDreads absorbedcontrolled flow
Cache absorbs read spikes; admission control protects the correctness-critical reservation path

A hierarchical strategy -- event -> section/block -> seat -- lets one hot event's load spread across multiple partitions internally, rather than concentrating entirely on a single shard. Deterministic seat-to-partition ownership (e.g. by section, or via consistent hashing for more even rebalancing as capacity changes) is one concrete way to implement this.

Deep Dive 21

General-Admission Inventory

General-admission tickets are quantity inventory, not identity inventory -- the exact seat doesn't matter, only the count.

The model simplifies to available_capacity / reserved_capacity / sold_capacity for the event (or a ticket tier within it). Reservation becomes an atomic conditional decrement of a requested quantity q, rather than a per-seat conditional update -- conceptually the same invariant as e-commerce inventory reservation, applied to admission capacity instead of SKU stock.

Deep Dive 22

Observability

Metrics should separate the read (availability) path from the write (reservation) path -- a spike in one is a very different incident from a spike in the other.

MetricWhy it matters
Seat-map read latencyBrowse-path health
Cache hit/miss rateAvailability read-model effectiveness
Availability freshness / invalidation lagHow stale the displayed seat map can get
Hold attempt/success/conflict rateContention level on the reservation path, overall and per-seat
Idempotency key reuse rateVolume of client retries actually being deduplicated
Active holds countCurrent reservation-hold pressure on the system
Expiry lagnow - expires_at for holds that are expired but not yet released -- see Hold Expiration Requirements
Hold duration distributionProduct/UX signal -- how long checkout actually takes in practice
Payment pending/unknown ageAmbiguous payment volume awaiting resolution
Confirmation latencyTime from payment resolution to booking CONFIRMED
Reconciliation mismatch countLeading indicator of a systemic booking/payment/seat integration issue
Outbox lagTime between a DB commit and the event reaching the broker
Ticket issuance lagTime from booking.confirmed to a usable ticket existing
Waiting-room depth / admission rateDemand pressure and how fast the room is letting users through
Booking DB saturationThe ultimate ceiling on reservation throughput
Hot partition/seat contentionConcentration of load on a specific event, section, or seat
Cancellation/refund lagTime from cancellation request to refund resolution

Invariants by entity

EntityInvariantMechanism
Seat (event-specific)At most one live allocation (an active hold or a confirmed booking) owns a seat for an event at any timeAtomic conditional reservation; multi-seat requests all-or-none in deterministic seat order
HoldA hold has its own identity; a retry reuses it; expired holds release only their own seatsIdempotency key; release scoped by hold_id and state; expires_at defines validity even if the worker is down
BookingHold, booking and payment are separate state machines; confirmation, seat transition and outbox event commit togetherGuarded transitions; one confirmation transaction; transactional outbox
PaymentAn unresolved payment never regresses a terminal state and never steals a seat now owned by someone elseExplicit policy for payment after expiry; validate events against the transition table; reconciliation
TicketOne ticket per confirmed booking, even under at-least-once delivery; cancellation is not the same fact as a refundIdempotent issuance; revoke the ticket in the cancellation workflow; reconcile refunds