Trade-offs & Decisions
The key architectural decisions in a ticket booking system, and the trade-offs that make each one non-obvious -- from concurrency control and hold-vs-immediate-purchase through admission strategy, cache freshness, schema shape, expiry mechanism, partitioning, messaging and multi-region ownership.
Locking vs. Optimistic Concurrency
How should the system prevent two concurrent requests from both acquiring the same seat? All three options enforce the same invariant -- they differ in contention behavior.
| Conditional update | Optimistic version | Row lock / pessimistic locking (SELECT ... FOR UPDATE) | |
|---|---|---|---|
| Held for | One statement | No lock held -- conflict detected on write | One short transaction only |
| Best fit | Ordinary single-seat reservation -- a strong default | Moderate contention where a retry can still validly succeed | Cases needing multiple related checks/writes in one transaction |
| Retry on conflict? | N/A -- rows_affected = 0 means try a different seat | Only for a transient version conflict, not a real ownership conflict | N/A -- lock serializes access within the transaction |
Immediate Purchase vs. Temporary Hold
Should the seat be held while the user completes payment, or must payment complete before anything is reserved?
| Immediate purchase | Temporary hold | |
|---|---|---|
| Flow | Payment must complete before any reservation is granted | Seat is held first; the user pays within the hold window |
| UX | Harder -- forces payment details to be ready before selecting seats, or risks losing the seat to someone else mid-form | Better for assigned-seat checkout -- the user fills a payment form without racing other buyers for the same seat |
| Complexity | Simpler state machine | Adds hold expiry, the payment/hold boundary, and more states to manage |
| Good fit for | General admission with ample capacity, or payment methods that resolve instantly | Assigned seats, scarce inventory, checkout flows with a real payment form |
Queue vs. Reject vs. Waiting Room
When reservation demand exceeds safe capacity, three different responses are available -- and a backend message queue is not the same thing as a user-visible waiting room.
| Backend queue | Reject with backoff | User-visible waiting room | |
|---|---|---|---|
| What it is | An internal buffering mechanism between services -- invisible to the user | Fast failure with a clear retry message | A visible admission gate the user actively waits in, with position/status feedback |
| Fairness | None inherent -- it's an implementation detail, not a user experience | None -- first request to retry successfully wins | Can implement an explicit fairness policy (FIFO, lottery, priority tiers) |
| Operational complexity | Low to moderate | Low | Highest -- admission tokens, queue state, reconnect handling |
| Abandonment risk | N/A | User leaves and may not retry | Users wait, but a long or opaque queue still risks abandonment |
Availability Cache Strategy
How stale can the seat map be, and what mechanism keeps it reasonably fresh? There is no single universal default TTL.
| Strategy | Trade-off |
|---|---|
| Fixed TTL | Simple and predictable; freshness is bounded by the TTL value, which should be chosen against a stated freshness SLO rather than a fixed universal number |
| Invalidation on state change | Fresher than TTL alone; adds complexity in tracking and propagating every seat-state change |
| Event-driven read model | The read model is rebuilt from the same events that drive booking state -- fresher and more consistent, at the cost of an event pipeline |
| Push (WebSocket/SSE) | A UX enhancement layered on top of any of the above -- users see updates without polling; still not a reservation authority |
| Cache bypass/refresh | Letting the client force a fresh read (e.g. right before attempting a reservation) trades a bit of load for a better-informed attempt |
Conditional Update vs. Version vs. Row Lock
A decision matrix for picking a concurrency mechanism given the actual access pattern.
| Access pattern | Recommended mechanism |
|---|---|
| Ordinary reservation, low-to-moderate contention per seat | Conditional update -- simplest, no extra column needed beyond state |
| Frequent legitimate updates to the same row for reasons other than ownership conflicts (e.g. metadata edits) | Optimistic version -- distinguishes 'someone else changed something' from 'someone else took ownership' |
| Multiple related rows need to be checked/updated together in one transaction (multi-seat) | Row lock (SELECT ... FOR UPDATE), held only for that short transaction |
| Extreme contention on one specific seat/partition | Consider serialized processing for that hot key -- see Event Shard vs. Seat Partition below |
Single-Seat vs. Multi-Seat Atomicity
Multi-seat bookings add real atomicity complexity beyond the single-seat baseline -- worth calling out as its own decision rather than assuming it's free.
| Single seat | Multi-seat, one partition | Multi-seat, spans partitions | |
|---|---|---|---|
| Mechanism | One conditional update | One transaction, deterministic lock order, verify all-or-none | Saga with temporary sub-holds and compensation, or constrain bookings to one partition |
| Complexity | Low | Moderate | High -- approaches distributed-transaction territory |
| Recommended default | N/A | Constrain the product so most multi-seat requests land in one partition where possible | Only take this on when the product genuinely requires cross-partition multi-seat selection |
See Architecture Deep Dive -> Multi-Seat Reservation for the mechanics, and Failure & Scale -> Multi-Seat Partial Conflict / Multi-Seat Deadlock for the corresponding failure modes. The cross-partition case is a saga in the general sense -- a sequence of local holds with explicit compensation if a later seat in the set fails.
Seat State vs. Separate Hold Record
Should hold/allocation state be embedded directly on the seat row, or tracked in its own table?
| Embedded on seat row | Separate hold/allocation entity | |
|---|---|---|
| Simplicity | Simpler for single-seat transitions -- one row, one update | More schema and transaction complexity |
| History/audit | Awkward -- overwriting the seat row loses the history of who held it before | Natural -- each hold is its own row, so history is inherent |
| Multi-seat modeling | Awkward -- no natural place to represent 'these three seats belong to one hold' | Natural -- one hold row can reference multiple seat IDs |
Expiry Scan vs. Delayed Timer
Several mechanisms can implement durable hold expiry -- they trade precision against operational simplicity.
| Mechanism | Trade-off |
|---|---|
| Indexed DB scan | Simple; scan cost is bounded by a good (state, expires_at) index and batching |
| Delayed queue message | Precise timing; adds a dependency on a delay-capable queue/scheduler |
| Workflow/timer service | Precise and integrated with the booking saga's own state, if one is already in use |
| Time-bucketed expiry | Groups holds by expiry window to make the scan/claim step cheaper at scale |
| Sorted-set accelerator (e.g. Redis) | Fast to query for near-term expiries; must remain an accelerator in front of the durable DB, not the authority itself |
Event Shard vs. Seat Partition
Sharding by event_id is a strong default for isolating unrelated events -- but it does not, alone, scale one blockbuster event.
| Shard by event_id only | Hierarchical: event -> section/block -> seat | |
|---|---|---|
| Isolation between events | Good -- each event's load stays on its own shard | Good -- same isolation, plus finer granularity within an event |
| Scaling one hot event | Limited -- the whole event still lands on one shard | Better -- load for one event spreads across multiple partitions |
| Multi-seat atomicity | Simple -- all of one event's seats share a partition | Harder when a selection spans partitions -- see Single-Seat vs. Multi-Seat Atomicity |
Reject vs. Admit
Given a fixed admission rate into the booking path, how should the rate itself be chosen?
Admission capacity should be sized against the slowest correctness-critical dependency in the chain -- the booking database's safe write throughput, the payment provider's realistic capacity, and the expiry infrastructure's ability to keep up -- not simply the number of stateless application servers that can be spun up. Admitting faster than the payment provider can realistically process, for instance, just relocates the bottleneck downstream. The admission gate itself is a rate limiting problem -- the same token-bucket-style capacity reasoning applies, just enforced at the entrance to the booking flow rather than per API call.
Polling vs. Push Availability
Should clients poll the seat map, or should the server push updates?
| Polling | Push (WebSocket/SSE) | |
|---|---|---|
| Simplicity | Simple, predictable request pattern | More moving parts -- connection management, fan-out |
| Freshness | Bounded by poll interval; can be relatively expensive at high frequency | Can feel closer to real-time |
| Reservation authority | Neither is the reservation authority -- both feed only the display/read model |
Push is a UX enhancement, not a correctness improvement -- the reservation path is authoritative regardless of which one delivers the display update.
Payment Timing vs. Confirmation
Some payment methods resolve synchronously; others are inherently asynchronous -- the confirmation path has to accommodate both without assuming one.
A valid checkout response can be PENDING when the chosen payment method resolves later via webhook or a redirect flow. The confirmation rule stays the same either way: before marking a booking CONFIRMED, the system must have durable evidence that payment actually succeeded and that the hold still validly owns the seats -- see Architecture Deep Dive -> Payment / Hold Boundary.
Orchestration vs. Events
The Booking Service can explicitly orchestrate the correctness-critical path, while everything downstream of confirmation stays event-driven.
Reservation, payment coordination, and confirmation benefit from one explicit owner (the Booking Service) that can see and drive the whole sequence -- this is where correctness lives. Ticket issuance, notification, and analytics can react to booking.confirmed independently via choreography -- they don't need, and shouldn't need, to be coupled into the same synchronous path.
Direct Publish vs. Transactional Outbox
Publishing booking.confirmed directly after the DB commit is simpler, but leaves a real dual-write gap.
| Direct publish | Transactional outbox | |
|---|---|---|
| Implementation cost | Lower | Higher -- an outbox table, a relay, relay-lag monitoring |
| Failure window | A crash between commit and publish silently drops the event | The event's existence is guaranteed by the same transaction that committed the booking |
| Consumer requirement | Must still be idempotent | Must still be idempotent -- at-least-once, not exactly-once |
Single-Region Owner vs. Multi-Region Active-Active
The baseline this module teaches is one authoritative write owner per event/partition -- multi-region active-active scarce inventory is a deliberate, advanced extension, not a default.
| Single-region owner | Multi-region active-active | |
|---|---|---|
| Complexity | Low -- one authoritative store decides every reservation | High -- needs conflict prevention and write-ownership fencing per seat/partition |
| Risk if done naively | N/A | Two regions can independently believe they each legitimately hold the same seat |
| When justified | The default for most ticketing platforms | Only when regional latency or availability requirements explicitly demand it, alongside a real fencing design |