Searching for a concert and buying a seat require different guarantees. Search can show slightly stale information and still be useful. A purchase needs an unambiguous decision about who gets the seat.

A ticketing system like the one we model when thinking about Ticketmaster must separate those operations. The design prioritizes availability for discovering and viewing events, and consistency for reserving inventory and confirming sales. These priorities take shape in how each operation behaves during a failure.

Requirements

Users must be able to search for events by text, location, category, and date. They also need to view the venue, performers, terms of sale, and seating map.

A purchase has two stages: a temporary hold and payment confirmation. The hold grants a period to complete the purchase; once it expires, inventory can be reassigned. Users must be able to check the status of their purchase even if they close the page or lose connectivity.

The central rule is that a seat at an event can have only one active confirmed sale. The system must also limit how much inventory a user can hold and for how long. For general admission tickets without assigned seating, the equivalent is preventing confirmed sales and active holds from exceeding available capacity.

Entities and interface

The catalog describes what is for sale; inventory and orders record who can buy it and what happened to that purchase.

EntityResponsibility
EventDate, category, venue, performers, and terms of sale.
Venue and seatPhysical layout, sections, and identifiable seats.
PerformerInformation about the performer and their relationship to events.
InventoryEach seat’s state per event, hold owner, and expiration.
HoldUser, held seats, identifier, and validity period.
Order and paymentServer-calculated price, purchase state, and provider reference.
TicketRight of admission issued when the sale is confirmed.

A REST interface can be organized as follows:

EndpointAction
GET /eventsSearch with filters for text, location, category, and date.
GET /events/{eventId}View details and a projection of the seating map.
POST /holdsRequest a temporary hold on the selected seats.
POST /ordersBegin purchasing a valid hold owned by the user.
GET /orders/{orderId}Check the outcome of the purchase.

The user’s identity can travel in Authorization: Bearer <JWT>. The server verifies the signature and relevant claims, such as issuer, audience, and expiration, and checks that the user has access to the requested hold or order. Price and hold ownership are validated on the server.

Hold and purchase operations accept an idempotency key so that retrying the same user and operation returns the previous result. A uniqueness constraint associates each hold with a single purchase order, even when requests arrive with different keys; that order can record multiple payment attempts. Attempts are coordinated atomically per order: before opening another, the previous one is resolved or canceled, and any redundant charge is recorded and compensated. The payment provider has its own authenticated notification channel; a message from the browser is not enough to mark an order as paid.

High-level design

The catalog and search serve frequent queries. The booking service controls inventory and orders in Postgres. The virtual queue regulates how many buyers enter the hold flow during a high-demand sale.

---
config:
  look: handDrawn
---
flowchart TD
    client["User"] -->|"Search and view"| catalog["Event catalog"]
    catalog --> search[("Elasticsearch<br/>Search index")]
    client -->|"Enter the sale"| queue["Virtual queue<br/>Admission with Redis"]
    queue --> booking["Holds and orders"]
    booking --> database[("Postgres<br/>Inventory and holds<br/>Orders and outbox")]
    booking <-->|"Payment and verified outcome"| payment["Payment provider"]
    database -.->|"Committed changes"| relay["Event publishing"]
    relay -.->|"Update projection"| search
    relay --> realtime["SSE or long polling<br/>Seating map"]
    realtime --> client

Postgres retains the authoritative state of the sale. Elasticsearch and the map are projections that can lag; creating a hold checks availability again in the transactional database.

Temporary holds and concurrency

An inventory row identified by (event_id, seat_id) represents the state of that seat at that event. The transition to held includes a hold_id, an owner, an expiration, and, if needed, a version that detects outdated operations.

The hold transaction must check availability and modify it atomically. It can use a conditional update or a row lock. If two buyers compete, only one obtains the transition; the other receives a conflict and chooses again. Uniqueness constraints protect inventory identity and prevent duplicate active sales.

ACID provides the tools for that coordination, but the transition conditions and constraints must express the business rule. Reading and then writing without coordination leaves open the possibility that two requests believe they obtained the same seat.

When holding multiple seats, all are acquired in one transaction or the operation is rolled back. Acquiring locks in a stable order helps avoid deadlocks. That transaction is short: the user completes payment afterward, without keeping database locks for the entire checkout.

Hold expiration and Redis

Redis can represent active holds with TTL keys and accelerate map queries. It can also coordinate provisional selections across service instances, avoiding repeated database writes for every selection change. This reduces reads and contention; it does not remove the transaction that grants a firm hold.

The database retains hold_id, owner, and expires_at. A new hold can reclaim a seat whose previous hold expired through a conditional transition, evaluating the deadline against a server time reference. Logical expiration is checked when operating on inventory: it does not depend on a cron job cleaning up the row at exactly the right time. Redis stops returning an expired key, but physical deletion and expiration notifications are not a precise clock for confirming sales. Redis TTLs and expiration columns serve different operational needs; neither replaces transactional conditions.

Example: a ten-minute hold

For one seat, the flow can be as follows:

  1. The server authenticates the buyer, creates a hold_id, and records an owner tied to that identity. A ticket_id identifies the inventory of a seat at an event, rather than just the seat number at the venue. The server sets one ten-minute deadline for the operation.
  2. A provisional acquisition can use SET hold:<ticket_id> <hold_id:owner> NX EX 600. Redis atomically creates the key and its TTL only if it does not exist; every instance uses the same shared key. This temporary exclusion, or lease, coordinates concurrent requests on that Redis primary, but does not yet grant a firm hold or guarantee ownership under every failure.
  3. The Postgres transaction checks availability and records the same hold_id, owner, and expires_at. A conflict rejects the request. Acquiring the Redis key does not allow this validation to be skipped.
  4. Only after commit is the firm hold published to the map projections. The Redis TTL is calculated from the time remaining until the same expires_at; updating the cache does not restart another ten minutes. If the transaction fails, the provisional acquisition is released only if it still belongs to that attempt.
  5. When confirming payment, Postgres again validates that the same hold_id belongs to the buyer, is still valid, and can transition to sold. Reading Redis is not enough to confirm the purchase.

Releasing or renewing a key requires comparing its token and modifying it in one atomic operation, for example with Lua. A GET followed by DEL leaves a race: the lease can expire, another buyer can acquire the key, and the earlier process can delete the new hold. Compare-and-delete prevents that deletion of another owner’s key; it does not by itself prevent a delayed process from attempting to act on inventory, which remains protected by Postgres identity checks and conditions.

Closing the browser does not automatically release the key. Early cancellation requires an authenticated request to the server, which cancels the buyer’s own hold in the database and conditionally releases its key. If that request never arrives, the deadline expires; a worker can clean up expired records later without determining when they stopped being valid.

A Redis timeout can leave the acquisition’s outcome uncertain, and a failover can lose a key. Recovery checks the state by hold_id and uses idempotent retries; a missing key does not grant rights over a seat that Postgres still holds. If continuing without Redis is allowed, admission is limited so the fallback does not overwhelm the database.

The map can combine database inventory with the Redis hold projection without querying the database for every browser update. That view can lag and is not an atomic snapshot across both systems: a missing key must not override an active sale or hold in Postgres. The user can receive a conflict even when the seat appeared available.

Payment confirmation

Holding and paying are two stages of the product. Postgres and the payment provider do not share a single ACID transaction, so the design needs explicit states, safe retries, and compensation.

The server creates an order associated with the hold and records the payment attempt. It then requests payment outside the inventory transaction. When verifying the outcome, a short transaction checks that the order is still eligible for confirmation and that the seats remain held under that same hold_id, which is still valid. It then marks the inventory as sold and records the issued tickets.

If payment arrives after the hold expires or the seat is reassigned, the flow must void or refund the charge according to the provider’s state. An old confirmation can never take ownership of a new hold. Compensation is also recorded and retried when necessary.

Notifications can be duplicated or arrive out of order. The consumer validates their authenticity, deduplicates by identifier, and applies only valid transitions. Reconciliation checks payments left in an uncertain state, for example after a timeout following a charge request. If the provider supports idempotency keys, a retry uses the same key within the window and conditions guaranteed by its contract. Outside that guarantee, the previous attempt’s state must be resolved before initiating another charge.

Search and map updates

Elasticsearch combines text search, filters, and geographic queries. Postgres maintains catalog and inventory data; the search index is updated asynchronously. Results may show an event whose last seat has just been sold, so availability is checked again when creating a hold.

To publish changes without losing them between commit and delivery, the transaction also writes a record to an outbox. A process publishes those records to the projections. Delivery can repeat: each consumer must deduplicate or apply versions idempotently.

The map can update through SSE, which keeps a stream of events from the server to the browser, or long polling, which holds a request until changes occur or its deadline expires. Each update carries a version or identifier. After a disconnection, the client retrieves available events or requests a new snapshot if part of the history is missing.

A recent map helps users choose, but hold confirmation remains the decision that grants the seat. The client must handle a conflict even if the seat appeared available when clicked.

Virtual queue and high-demand sales

When an event concentrates demand, scaling the catalog does not remove competition over its inventory. The virtual queue keeps waiting controlled and admits buyers at a rate supported by checkout, the database, and the payment provider.

Redis can maintain queue positions and admission windows. The policy must define ordering, per-identity limits, reconnection handling, and behavior if the queue fails. Each admitted buyer receives a verifiable permission with a bounded lifetime. Hold endpoints check that permission so direct API access cannot bypass the wait.

Admission grants access to the purchase flow; the seat is obtained through the hold transaction. Data can be partitioned by event, but a popular sale still creates a concentration point that must be managed with concurrency control and admission limits.

What to verify

The central tests make multiple buyers compete for the same seat, expire a hold during payment, and repeat requests or notifications. They must also cover late payments, Redis failures, outbox lag, and reconnecting to the map.

Useful signals include hold conflicts, time in queue, checkout duration, payments without confirmed orders, pending compensation, and projection lag. The system works when it can explain who obtained each seat, what happened to the money, and how an incomplete operation recovers.