Ticket Booking System Design Interview Guide: Seat Holds, Concurrency, Payments, and Fairness
Quick Overview
Design a high-demand ticket booking system with safe seat holds, concurrency control, idempotent payments, fair admission, expiry, and reconciliation.
A ticket-booking design is convincing when it can explain exactly which buyer owns a seat during a race. A queue can reduce load, a cache can improve browsing and a payment provider can collect money; none of those alone establishes the authoritative seat owner. Start with the invariant, then trace expiry, a second buyer and a late payment callback.
Use system design practice to rehearse the surrounding architecture. The fixture below makes the seat and payment race concrete.

Define the authoritative state and the customer promise
A reserved-seat design should have one authoritative ownership path. Browsing data may be stale, but a successful booking must enforce the seat’s uniqueness. Record event ID, seat ID, hold ID, owner, expiry, version and state. Decide whether multi-seat checkout must be all-or-nothing and when a seat is eligible for another buyer.
Do not make Redis expiry notifications the only source of truth for ownership. Expiry notification timing and delivery are not an atomic seat-booking guarantee. A conditional database transition can compare hold identity, version, state and the authoritative clock in one transaction. Constraints and locking need to match the database and isolation level you actually choose. PostgreSQL locking, PostgreSQL constraints, Redis expiration behavior.
Separate payment-attempt state from seat ownership. A charged payment can need compensation if its old hold no longer owns the seat. Callback idempotency means the same event cannot repeat the transition or refund instruction; it does not mean every paid event is eligible to create a booking. Acknowledge webhook receipt according to the provider’s requirements and durably schedule processing and compensation. Stripe webhook guidance.
Trace the races instead of saying “use a lock”
Use this hypothetical fixture: event E1, seat A7, a five-minute hold, and two customers. The database is the authority. Every hold has an unguessable hold ID, an owner, an expiry and a monotonically changing version. Payment has a separate attempt ID and idempotency key. Assume the inventory operations below execute atomically against the authoritative seat row.
| Step | Event | Authoritative result |
|---|---|---|
| 1 | Customer A acquires available A7 | HELD by A, hold H1, version 41 |
| 2 | Customer B concurrently attempts A7 | Conflict; no second active hold |
| 3 | A’s hold expires; B acquires the seat | HELD by B, hold H2, version 42 |
| 4 | A’s delayed payment-success callback arrives | Record paid attempt P1; H1/version 41 cannot confirm H2/version 42 |
| 5 | Reconciliation sees P1 paid but no valid A booking | Start the defined refund/void workflow; tell A the booking was not confirmed |
| 6 | The same callback is delivered again | Same payment-attempt record and refund operation; no duplicate booking or refund |
| 7 | B pays while H2 is valid | Conditional transition to CONFIRMED for B; create one booking |
The delayed callback must compare the hold identity and version, not only seat_id. Matching A7 alone could let A’s old payment take B’s seat. The expiry worker must also be conditional: “release H1/version 41 if still held,” not “set A7 available.” An old expiry job that arrives after B’s acquisition must be a no-op.

Test both orders of the expiry/payment race
If A’s confirmation transaction commits before expiry under the agreed clock/validity rule, the seat becomes confirmed and the expiry job does nothing. If expiry or a replacement hold wins first, A’s confirmation fails its predicate and payment reconciliation follows the late-payment policy. Define which clock and boundary determine validity. A background worker’s execution time is not automatically the hold’s authoritative deadline.
Use a database uniqueness constraint for the event/seat booking invariant and keep payment attempt IDs unique. The transaction writes booking state and a durable follow-up intent together where possible. A worker can then retry confirmation messages or refunds. External payment and the inventory database do not form one simple ACID transaction; describe the recovery protocol rather than claiming a lock solves payment uncertainty.
For multi-seat purchases, lock or conditionally update the requested seats in a consistent order and require all seats to pass the same validation before committing. If any seat conflicts, roll back the complete inventory transaction. Confirm the product’s payment-authorization policy separately so a partially failed seat acquisition does not become a falsely confirmed order.

Make one admission decision from an explicit capacity model
Suppose a load test measures 2,000 inventory transactions per second at acceptable latency, and one admitted checkout averages five such transactions. Reserve 30% headroom: 2,000 × 0.7 / 5 = 280 admitted checkouts per second under that workload. Start at 250/s, compare measured transaction amplification and p99, and lower admission if retries increase work. These are exercise figures, not capacity claims about PostgreSQL or a ticket provider.
Keep browsing traffic on replicated/cacheable paths. A waiting-room token permits entry to checkout; it does not promise a seat. Enforce admission at the service boundary so a client cannot bypass the queue. Bound tokens by event and expiry, count retries against the appropriate policy and define accessibility/refresh behavior. FIFO ordering alone does not establish fairness if one person can obtain many identities or if reconnects lose a valid position.
Pass the design mock only after you can replay the seven-step fixture, both race orders, a duplicate callback, an old expiry worker and a refund-worker crash. Check final seat owner, booking count, paid-attempt count and pending compensation. This is an editorial correctness checklist. A local state diagram helps explain it; actual concurrency claims require database-level tests under contention.
Connect the race trace to APIs and storage
For the original rehearsal, POST /holds accepts an event, requested seats and a request idempotency key. It returns the hold identity, authoritative expiry and current version, or a clear conflict. Do not return a successful hold based only on cached availability. Repeated submission with the same key and payload should recover the same outcome; key reuse with a different payload needs a conflict policy.
POST /bookings binds a checkout attempt to its hold. Persist the payment attempt before invoking the provider, use the provider’s supported idempotency mechanism and retain enough identity to reconcile a response that is lost. The webhook locates that attempt and performs an eligible booking transition or durably schedules compensation. A callback cannot choose a seat owner from its payment status alone.
A status endpoint reads durable attempt and booking state. It can return pending, booked, compensation pending or compensated. These states help the client avoid retrying a completed charge because the page timed out. A reconciliation job compares unresolved attempts with the provider and the authoritative inventory path; it must be replay-safe itself.
Separate a transaction’s committed state from a message announcing it. A transactional outbox can make the intent to notify or compensate durable with the local state change, then deliver it asynchronously. The receiver still needs idempotency because dispatch can repeat.
Estimate admission using the constrained path
In the supplied model, a measured write path sustains 2,000 transactions per second and one admission uses five transactions on average. Reserving thirty percent of that capacity leaves 1,400 transactions per second, or 280 admissions per second by the simple average model. Starting at 250 provides a margin within that model; it is not a benchmark for every database or ticketing service.
Ask what happens under contention. A popular seat or a multi-seat overlap can increase retries and transaction cost. The average five-transaction assumption may fail precisely during the event you care about. Measure lock waits, aborts, write latency, pending payments and completed bookings under a realistic hot-event workload.
If clients retry immediately on conflicts, they can multiply offered load without increasing useful bookings. Return an understandable conflict or retry instruction, bound retries and add jitter where appropriate. Monitor accepted admissions and successful bookings separately: admitting a buyer to checkout does not guarantee they acquire a seat.
The interview answer should finish with a test plan containing both race orders, duplicate callbacks, a crash between charge and local completion, an old expiry against a new hold and a multi-seat overlap. Name which invariants each test checks. Running those cases against the actual database is the step that turns the proposed transition policy into meaningful implementation evidence.
Fairness and overload need an explicit policy
A waiting room is admission control, not a seat reservation. Explain the policy: arrival order, a randomized presale cohort, per-account limits or another product rule. State how retries retain a legitimate position and how abusive traffic is constrained. A FIFO label does not answer bot resistance, accessibility, reconnect behavior or payment deadlines.
At a hot event, prefer a bounded admission rate and fast, understandable overload behavior to allowing every request into a saturated write path. Separate browsing from checkout capacity. Estimate offered load, database transaction cost and payment-provider latency; then measure rather than treat the estimate as proven throughput.
For multi-seat bookings, lock in a consistent seat order or use a transaction pattern that validates all requested seats before committing any. If two buyers request overlapping sets, trace the interleaving and the losing buyer’s response. Deadlock handling and retry limits remain necessary when the database can abort a transaction.
A 45-minute design rehearsal
Use five minutes for requirements and the ownership invariant, ten for APIs and storage, ten for the race fixture, ten for payments and recovery, and ten for capacity, fairness and review. Finish with POST /holds, POST /bookings, a payment webhook and a status endpoint. The server, not the client’s clock, determines expiry.
Describe two recovery jobs: one releases or marks expired holds conditionally; another reconciles unresolved paid attempts with durable booking and compensation state. Both must tolerate replay. The customer-facing status should distinguish pending payment, confirmed booking and refund processing instead of returning a generic success whenever a charge exists.
Should a late successful payment confirm the old hold?
Only if that hold still satisfies the authoritative eligibility condition. In the supplied trace, a new hold owns the seat, so the old payment goes to compensation. It must not displace the new buyer.
Is a successful single-threaded mock enough?
No. It can demonstrate the intended state transitions. Verify the actual uniqueness, transaction isolation, retries and races against the chosen storage system before making a production guarantee.
What should the final answer emphasize?
Who owns the seat, which transition is atomic, how duplicated and late events behave, and what the customer sees after failure. Explain component names only after those contracts are clear.
Sources and Further Reading
- PostgreSQL locking
- PostgreSQL constraints
- Redis expiration behavior
- Stripe webhook guidance
- Stripe idempotent requests
Editorial revision: 8 October 2026. Worked fixtures and practice rubrics are original PracHub material. Current hiring details remain role-specific; unavailable source pages and unverified timings are identified in the text.
Comments (0)