Skip to content
Back to all concepts
System DesignConcurrencyDatabasesDistributed Systems

Ticket Booking: Durable Holds and Concurrent Checkout

A worked seat-booking design that prevents stale holds from becoming sales and handles payment completion after expiry.

By ScaleWikiUpdated 4 min readAdvanced

Ticket booking: the seat record decides who owns it

This is an independent booking-system exercise, not an account of Ticketmaster's production implementation. The key invariant is simple: one event seat can have at most one confirmed booking. A waiting room and cache can reduce load, but the authoritative store must enforce that invariant when requests race.

Make the hold durable

Represent each seat with (event_id, seat_id), state, version, hold token, holder, expiry, and booking ID. The state progression is available → held → payment_pending → sold, with explicit expiry and cancellation paths.

To acquire a hold, use a conditional update inside a short transaction. The following SQL is illustrative; the application must also authorize the event and user and bind values safely:

sql
UPDATE seats
SET state = 'held',
    hold_token = :new_token,
    holder_id = :user_id,
    hold_expires_at = CURRENT_TIMESTAMP + INTERVAL '5 minutes',
    version = version + 1
WHERE event_id = :event_id
  AND seat_id = :seat_id
  AND (state = 'available'
       OR (state = 'held' AND hold_expires_at <= CURRENT_TIMESTAMP))
RETURNING version, hold_token, hold_expires_at;

One returned row means the hold was acquired. Zero means this attempt did not acquire it. Do not first read “available,” then issue an unconditional update. For adjacent seats, acquire all requested seats in one transaction, using a consistent lock order, or roll back the whole selection.

Row locks are a valid option for this short transaction. Their cost depends on contention and transaction duration. Do not hold a database transaction open during the user's five-minute checkout. PostgreSQL documents the behavior of row locks and their release at transaction end. PostgreSQL explicit locking.

The expired-hold race

Alice receives token A for seat 1A. Her browser pauses. The hold expires, Bob acquires token B, and Alice's old payment request arrives.

A Redis key expiring does not stop Alice's paused process from running. An unconditional UPDATE ... SET state='sold' would let it overwrite Bob's claim. Our checkout instead requires the current hold token, holder, and unexpired deadline to match. A stale token cannot authorize a new transition.

Redis may cache the seat map and countdown. The database remains the authority. The countdown is informative; the server's time and state determine whether checkout can proceed.

Coordinate with payment without a long lock

In a short transaction, validate the hold and move it to payment_pending, attaching a unique order and payment-operation ID. Record a bounded payment deadline and a durable dispatch event. Then contact the provider outside the transaction, using idempotency for retries.

If authorization succeeds while the operation is still valid, commit the sale and create the unique booking. If the payment outcome is unknown, keep the seat reserved under the defined pending policy while reconciliation runs. Do not immediately release the seat and assume the payment failed.

If the product must release an unresolved reservation after a deadline, record that decision. A late success must enter a void/refund workflow rather than silently issuing a second ticket for a seat already sold. Compensation itself can fail, so its status needs tracking. See payment workflows for the timeout and reconciliation problem.

A waiting room is a capacity control

Assume 100,000 users arrive in one minute, but testing shows the checkout service sustains 300 active checkouts while meeting its latency target. If a session occupies a slot for an average of 90 seconds, admitting approximately 3.3 users per second sustains that concurrency at steady state. Allowing 500 entrants per second would rapidly exceed it.

These are illustrative inputs, not a benchmark. Admission should react to active sessions, hold contention, and provider health. A signed, expiring admission token prevents clients bypassing the queue. Define fairness explicitly: arrival order, randomized pre-sale allocation, and priority access are different product rules.

Sharding by event can isolate unrelated events, but one popular event remains a hotspot. Cache browsing, limit hold attempts, and keep the sale transaction small before reaching for a more complex partition scheme.

Test the invariant under failure

Run simultaneous hold requests for the same seat, replay an old token, and delay payment success until after the pending deadline. Restart the service after the database commits but before it acknowledges the client. Each retry must return the same booking or a clear unresolved status, never a second sale.

Check the authoritative records after the test: one confirmed booking per seat, every charged-but-unfulfilled order visible for resolution, and no expired token able to change ownership. Also measure abandoned holds and contention. A full-looking seat map can mean genuine demand or merely a broken expiry process; the two need different fixes.