Systems

Designing Ticketmaster: how to sell the last seat exactly once

Follow Alice and Bob through search, caching, a Redis hold, payment, and the one database transition that sells the last seat exactly once.

SSScott SantinhoJuly 25, 202621 min read
A centered stadium connected to ticket inventory, payment, queue, and secure infrastructure

Two buyers, one seat

At 10:00:00, Alice and Bob open the same concert and both see seat A-14 as available. At 10:00:01 they press Reserve within a few milliseconds of each other. Both browsers can show the same cached result, but neither display reserves the ticket. Only the booking service's hold operation can decide who receives a checkout window, and the final database transaction must still ensure that the seat is never sold twice.

We will carry that exact race through discovery, caching, reservation, payment, expiry, and recovery. The chosen design has one deliberate split: search and browsing are eventually consistent read models; a Redis lease arbitrates the temporary hold; PostgreSQL is the durable authority that commits the final sale. Every faster layer may be stale or disappear without changing who ultimately owns A-14.

The chosen architecture

Event organizers write canonical event and venue data to PostgreSQL. A transactional outbox emits catalog changes to an indexer, which builds denormalized Elasticsearch documents for full-text search. Event pages and seat maps use CDN and Redis caches with explicit freshness budgets. When Alice clicks A-14, the booking service crosses out of those read models and tries an atomic, expiring Redis hold. If Alice wins it, a durable in-progress booking is recorded. Payment confirmation then runs a database transaction that conditionally changes the ticket from available to sold, confirms the booking, and writes an outbox event for email and ticket delivery.

This is one composed architecture, not a list of interchangeable tools. Elasticsearch answers which events match this query? by searching denormalized event documents with text matching, filters, sorting, and pagination, without touching ticket ownership. Redis performs the atomic lease acquisition and automatic expiry that give one buyer a bounded checkout window. PostgreSQL validates the current durable inventory row and records the single final transition to sold. The waiting room meters how many buyers may reach the booking path at once. Each subsystem has one job and a stated failure boundary.

The API gateway verifies the buyer's identity and enforces request budgets per account and IP before routing work to horizontally scaled, stateless service instances. Requests that exceed those budgets receive a 429 response before they consume search, booking, or database capacity. The search service translates the public text, filter, sort, and pagination parameters into an allowlisted Elasticsearch query, applies the platform's ranking weights, and returns stable event IDs. The event service then assembles event, venue, performer, seat-map, and approximate availability data for those IDs. The booking service alone may create a hold, coordinate payment, and request the final inventory transition. Keeping those responsibilities explicit prevents a fast read path from quietly becoming a second write path.

The reader can follow the system in five steps: discover an event, load its current seat view, acquire a temporary hold, pay and commit the sale, then reconcile notifications and caches. Alice and Bob may share every component in the first two steps. Their outcomes diverge only when the booking service tries to acquire the hold for A-14.

Alice and Bob send concurrent requests for seat A-14; Alice acquires the hold while Bob receives a conflict.
The race is decided at the hold boundary, not by which browser first painted the seat green.

1. Event discovery: index for questions, not ownership

Search traffic is broad and read-heavy: jazz in Paris next weekend, partial artist names, venue filters, spelling mistakes, sorting, and pagination. Running each request as joins and wildcard scans against the transactional catalog database would couple a flexible public query workload to organizer writes. Instead, the system creates one denormalized Elasticsearch document per event. It contains the searchable artist and venue text plus filterable city, category, date, and sales-state fields.

PostgreSQL keeps the canonical entities normalized. A User represents the authenticated buyer. An Event references its Venue and one or more Performers. A Ticket belongs to an Event and carries the seat, price, and durable sale status. A Booking belongs to a user and records the selected ticket IDs plus payment state. The Elasticsearch document intentionally duplicates the event, venue, and performer fields needed for retrieval, but it never becomes the canonical copy of those entities.

An organizer transaction updates the canonical event row and inserts an outbox record in the same commit. A change-data-capture worker reads that outbox, publishes an event keyed by event ID, and the indexer upserts the corresponding document. Reprocessing is safe because the event ID is the document ID and the catalog version only moves forward. Elasticsearch builds an inverted index for text retrieval and separate indexed fields for filters; the search service owns query construction so public clients cannot send arbitrary Elasticsearch queries.

The index is deliberately a projection. means a newly changed event may not appear until a refresh makes the indexed operation searchable. That short delay is acceptable for discovery. If Elasticsearch is unavailable, known event URLs can still load from the catalog service, and the index can be rebuilt from PostgreSQL plus the outbox. It never contains the ownership decision for A-14.

Repeated public searches use normalized query parameters so Elasticsearch can reuse filter and request-cache entries. A CDN may also cache a short-lived search response when the result is identical for every buyer, but personalized ranking bypasses that edge cache. Search-cache keys include every filter and sort field, and short TTLs bound staleness when a catalog invalidation is missed.

Alice searches for the artist and receives event ID evt_42. Bob reaches the same event through a promoted link. Their routes differ, but both land on the same canonical event ID. Search relevance affected how they arrived; it will play no role in which buyer may reserve a seat.

Event catalog changes flow through an indexer into Elasticsearch while the availability cache remains a separate read model.
Discovery tolerates indexing delay; a booking attempt always crosses into the authoritative path.

2. Event page and availability read path

The browser first calls GET /search to obtain stable event IDs, then GET /events/:eventId to load one event. The API gateway performs authentication, rate limiting, and routing; it does not contain event or booking rules. The event service reads canonical details from PostgreSQL and combines them with cacheable presentation data before returning the page model.

The event page combines data with different rates of change. Artist copy, venue layout, and show time can sit behind the CDN for tens of seconds. The seat map geometry is versioned and cached for much longer because it is static. Availability is a separate overlay keyed by event and section. The availability service reads a short-lived Redis snapshot first, falling back to PostgreSQL when the snapshot is absent.

That overlay is intentionally approximate. A two-second TTL and best-effort invalidation on hold, expiry, and sale keep it useful without pretending that thousands of browser views are synchronized. The response includes an asOf timestamp so the UI can explain that availability can change. It may paint A-14 green for both Alice and Bob because their reads raced just before Alice’s hold. That is not a correctness bug; accepting a reservation without authoritative arbitration would be.

For a popular event, the event service can stream hold, expiry, and sale notifications to connected browsers with server-sent events. The stream improves how quickly Bob sees A-14 change color, but it is still a read-model update. If the connection drops or an event is missed, the client refetches the availability overlay. Neither an SSE message nor the absence of one grants ownership.

When a buyer selects a seat, POST /holds submits the stable event ID, ticket ID, authenticated buyer ID, and a client-generated idempotency key. The booking service verifies that the ticket belongs to the event and is not already durably sold before it attempts the hold. It does not trust a price, status, or expiry copied from the browser. Prices and ticket identity are resolved server-side from the catalog and inventory records. After payment, POST /bookings/:bookingId/confirm enters the idempotent confirmation path.

Alice and Bob receive the same approximate seat view assembled from cached geometry, a short-lived availability overlay, and canonical event data.
The event API can paint A-14 green for both buyers because this read path describes availability; it does not grant a hold.

3. The temporary hold: one expiring Redis lease

At 10:00:01, both requests reach the booking service. It creates the key hold:evt_42:A-14 and asks Redis to set it only if it does not exist, with a ten-minute expiry. The value is not simply true; it is an unguessable hold token bound to the booking and buyer. makes acquisition atomic: Alice receives success, while Bob sees that the key already exists and receives a conflict containing the current hold deadline.

Why put this boundary in Redis? A popular onsale can create enormous short-lived contention, and most holds expire without becoming orders. An in-memory atomic operation isolates that burst from the durable inventory database and gives an expiry even if the booking process crashes. The TTL is part of the contract, not cleanup trivia: a hold that cannot expire would leak inventory whenever a browser disappears.

After acquiring the lease, the service inserts or reuses an in_progress booking identified by Alice’s idempotency key, storing the hold token hash, ticket ID, price snapshot, and expiry. If that durable write fails, the service releases the Redis key only when its token still matches. This token check prevents a delayed cleanup from deleting a newer buyer’s lease. The is the deepening reference for token-owned release and bounded lock validity.

The acquisition example below is a normal Redis command because SET ... NX EX is already one atomic operation. Lua is needed only for release, where reading the stored token and deleting the key must happen together. Splitting the examples makes the boundary explicit: Redis syntax acquires the lease; the short Lua script prevents a stale owner from releasing somebody else's lease.

Seat A-14 moves from available to held by Alice and then sold, or returns to available when the hold expires.
Held is a lease with an owner and deadline, not a durable sale.
The lease is fast arbitration; the final sale is a conditional database transition.

Redis

SET hold:evt_42:A-14 <alice_hold_token> NX EX 600

Lua

if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0

SQL

BEGIN;

UPDATE tickets
SET status = 'sold', order_id = :order_id, sold_at = now()
WHERE id = :ticket_id
  AND event_id = :event_id
  AND status = 'available'
RETURNING id;

-- Continue only when exactly one ticket row was returned.
UPDATE bookings
SET status = 'confirmed', payment_id = :payment_id
WHERE id = :booking_id AND status = 'in_progress';

INSERT INTO booking_outbox(event_id, booking_id, kind)
VALUES (:event_uuid, :booking_id, 'booking.confirmed');

COMMIT;

4. Hold lifecycle: acquire, use, extend narrowly, expire

Alice now sees a server-counted deadline. Every checkout request carries her booking ID and hold token; the booking service rejects Bob’s attempt to reuse either. Alice may fetch her booking after a browser refresh because the durable record survives, while Redis still enforces the remaining lease. The system never extends a lease merely because the page is open. Otherwise, an abandoned tab could hoard inventory indefinitely.

When Alice submits payment just before expiry, the service may perform one bounded compare-and-extend operation after confirming that her token still owns the key. The extension covers the processor’s expected response window; it does not restart a fresh ten minutes. Meanwhile a scheduled expiry worker marks overdue in_progress bookings as expired and emits an availability event. Redis TTL handles the hot-path release, while the database worker makes durable history and downstream caches converge.

If Alice abandons checkout, the lease disappears at 10:10:01. Bob’s retry can then acquire a new token. A delayed request from Alice cannot release Bob’s lease because the tokens differ, and it cannot confirm against Alice’s expired booking. Conceptually A-14 moved available → held-by-Alice → available → held-by-Bob, even though the temporary “held” state is represented by Redis plus an in-progress booking rather than by treating Redis as permanent inventory.

Alice's hold expires, Bob acquires a different token, and a delayed Alice cleanup is rejected by the token check.
Expiry frees capacity, while token ownership prevents an old request from deleting Bob's newer lease.

5. Payment and durable confirmation

Payment is a process, not one synchronous call. The service creates a payment operation for the durable booking using a stable idempotency key. Retrying after a network timeout must address the same processor operation; documents this retry contract. The processor may report success in the HTTP response, later through a webhook, or both. Both signals enter the same confirmBooking function.

That function first records the provider event ID under a unique constraint so duplicate deliveries become no-ops. It loads the in-progress booking, validates amount and currency against the server-side price snapshot, and attempts the conditional ticket update shown above. Under PostgreSQL’s default , concurrent updates to the same row wait and then re-evaluate their condition. Only a row still marked available is returned. In the same transaction, the winning booking becomes confirmed and a durable outbox event is inserted.

The outbox publisher sends booking.confirmed to ticket generation, email, analytics, and cache invalidation after the commit. Those consumers deduplicate by event ID. This “commit first, notify second” ordering prevents a confirmation email for a sale that later rolled back. If publishing is down, the outbox row remains for retry.

Now consider the hardest race. Alice’s processor succeeds after her hold has expired, and Bob has already started paying under a newer hold. Redis alone cannot solve this cross-system overlap. The database condition ensures only one booking can sell A-14. The loser is marked payment_succeeded_inventory_lost and enters an automatic refund workflow. We reduce the probability with the narrow payment extension and a payment deadline, but we still design a compensating path because no lease can make an external processor transactional.

Payment responses and webhooks converge through idempotent booking logic before inventory, outbox, and confirmation are updated.
A successful charge is evidence to reconcile; the database transaction is what records the sale.

6. Cache layers and explicit freshness budgets

Caching works here because every layer has a narrow responsibility. The CDN caches public event shells and static seat geometry. Redis caches catalog objects and availability overlays. Elasticsearch is itself a search-optimized projection. None is consulted to prove the final sale. Invalidation accelerates convergence after a hold, expiry, catalog change, or sale. TTL remains the recovery mechanism when an invalidation is missed.

The practical rule is to match staleness to consequence. An artist description being thirty seconds old is harmless. A seat map being two seconds old can disappoint Bob, so the UI must handle a conflict cleanly. A final inventory decision being two seconds old can double-sell a ticket, so it is never made from cache. The architecture does not promise “always fresh”; it promises that stale reads cannot cross the correctness boundary.

CDN event shell + seat geometry60 s for event copy; hours for versioned geometryPurge on catalog publish; immutable URL for geometryPresentation only
Elasticsearch event indexNear-real-time refresh, normally around secondsVersioned outbox/CDC upsert; rebuild from catalogDiscovery and ranking
Redis availability overlay2 s snapshotBest-effort hold/sale/expiry event plus TTLWhat the browser paints, never Reserve
Redis hold key10 min owner-bound leaseToken-checked release or automatic TTL expiryTemporary checkout exclusivity
PostgreSQL inventoryCurrent committed rowNot a cache; transactional update and constraintsFinal sale
One concrete cache policy for this design; values are starting budgets, not universal constants.

7. Alice and Bob under concurrency and failure

The happy path is now precise: both buyers may discover the event and read the same cached green seat; Alice’s atomic Redis acquisition succeeds; Bob’s fails immediately; Alice pays using a stable key; one PostgreSQL update sells A-14; the outbox confirms the result and invalidates read models. The system is not correct because every component agrees instantly. It is correct because disagreement is allowed only before one narrow commit boundary.

Failures are handled according to what was durable. If the booking service crashes after Redis acquisition but before the booking insert, the token-checked cleanup tries to release the lease and the TTL is the final safety net. If it crashes after the booking insert, Alice can resume from that record. If the payment response is lost, the idempotency key and webhook recover the same operation. also requires the consumer to tolerate duplicate events, which the unique provider-event record does.

Redis failover deserves an honest limit: a lease can be lost early under some failure modes. That is why the lease reduces contention but does not certify a sale. The conditional PostgreSQL update still prevents duplicate ownership. A short Redis outage can temporarily reject new holds rather than falling back to an unsafe check-then-set path; browsing remains available, and the waiting room can slow admission until hold capacity recovers.

Both see A-14 greenReads a 2 s availability snapshotReads the same snapshotThe snapshot is explicitly non-authoritative
Concurrent ReserveSET NX EX succeeds with Alice’s tokenSET NX EX fails and returns conflictOne atomic key acquisition, no check-then-set race
Alice retries after timeoutReuses booking and payment idempotency keysStill cannot acquire an unexpired leaseRetries address the original operations
Late payment overlaps Bob’s new holdPayment may have succeeded externallyMay be paying after Alice’s expiryOne conditional database sale wins; loser is refunded
Duplicate webhookReceives one confirmed bookingReceives no false confirmationUnique provider event and idempotent confirmation
The running scenario, including the failures that cross subsystem boundaries.

8. The waiting room limits contention; it does not allocate seats

For a hot onsale, even cheap failed hold attempts can overload authentication, booking, Redis, and PostgreSQL. An edge waiting room admits a measured number of buyers per minute and caps active sessions before requests reach the origin. provide one authoritative example of threshold-based admission. Capacity should be driven by measured booking throughput and latency, then reduced automatically when the hold or database tier is unhealthy.

Alice’s admission token lets her enter the booking experience; it does not reserve A-14. Bob may enter a moment later and still win a different seat. Keeping the queue separate from inventory means a queue restart cannot sell, release, or transfer a ticket. It controls concurrency so the correctness path has enough capacity to make its decision.

An edge waiting room meters Alice into the booking path while Bob remains queued and origin health controls the admission rate.
Admission protects booking capacity; its token never owns or allocates seat A-14.

Whole-system summary

The focused figures above explain the local mechanisms. The Nodefall canvas below now shows the complete request map: Client, CDN, API gateway, Search service, Elasticsearch, Event service, Redis event cache, PostgreSQL, virtual waiting queue, Booking service, Redis ticket lock, and Stripe. It also names the public APIs, canonical entities, CDC path, and ten-minute hold contract. Follow Alice and Bob through the same discovery and event-read paths, then compare their outcomes at the ticket lock and the final PostgreSQL transaction.

Ticket booking: discovery, reservation, and one authoritative sale

The complete request map separates discovery, event reads, admission, temporary ticket locks, payment, and the authoritative PostgreSQL sale.

Open canvas

Sources and further reading

This article’s problem framing and a substantial share of its architectural facts were drawn from . It was used as the primary technical reference for discovery, reservation contention, Redis leases, caching, and peak-load admission. The Alice-and-Bob scenario, wording, selected composition, failure analysis, and visuals here are original.

Claims were cross-checked against the linked official Elasticsearch, Redis, PostgreSQL, Stripe, and Cloudflare documentation. Those links are placed beside the concept they deepen, and each tooltip defines the highlighted concept in plain language rather than repeating the URL.