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.

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.

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.

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.

Redis
SET hold:evt_42:A-14 <alice_hold_token> NX EX 600Lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0SQL
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.

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.

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 geometry | 60 s for event copy; hours for versioned geometry | Purge on catalog publish; immutable URL for geometry | Presentation only |
| Elasticsearch event index | Near-real-time refresh, normally around seconds | Versioned outbox/CDC upsert; rebuild from catalog | Discovery and ranking |
| Redis availability overlay | 2 s snapshot | Best-effort hold/sale/expiry event plus TTL | What the browser paints, never Reserve |
| Redis hold key | 10 min owner-bound lease | Token-checked release or automatic TTL expiry | Temporary checkout exclusivity |
| PostgreSQL inventory | Current committed row | Not a cache; transactional update and constraints | Final sale |
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 green | Reads a 2 s availability snapshot | Reads the same snapshot | The snapshot is explicitly non-authoritative |
| Concurrent Reserve | SET NX EX succeeds with Alice’s token | SET NX EX fails and returns conflict | One atomic key acquisition, no check-then-set race |
| Alice retries after timeout | Reuses booking and payment idempotency keys | Still cannot acquire an unexpired lease | Retries address the original operations |
| Late payment overlaps Bob’s new hold | Payment may have succeeded externally | May be paying after Alice’s expiry | One conditional database sale wins; loser is refunded |
| Duplicate webhook | Receives one confirmed booking | Receives no false confirmation | Unique provider event and idempotent confirmation |
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.

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.
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.
