Skip to content
SeeItFlow
System Design · Trade-offs & Decisions · ~20 min read

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.

Mangesh MandlikLast updated:
Decision 01

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 updateOptimistic versionRow lock / pessimistic locking (SELECT ... FOR UPDATE)
Held forOne statementNo lock held -- conflict detected on writeOne short transaction only
Best fitOrdinary single-seat reservation -- a strong defaultModerate contention where a retry can still validly succeedCases needing multiple related checks/writes in one transaction
Retry on conflict?N/A -- rows_affected = 0 means try a different seatOnly for a transient version conflict, not a real ownership conflictN/A -- lock serializes access within the transaction
Decision 02

Immediate Purchase vs. Temporary Hold

Should the seat be held while the user completes payment, or must payment complete before anything is reserved?

Immediate purchaseTemporary hold
FlowPayment must complete before any reservation is grantedSeat is held first; the user pays within the hold window
UXHarder -- forces payment details to be ready before selecting seats, or risks losing the seat to someone else mid-formBetter for assigned-seat checkout -- the user fills a payment form without racing other buyers for the same seat
ComplexitySimpler state machineAdds hold expiry, the payment/hold boundary, and more states to manage
Good fit forGeneral admission with ample capacity, or payment methods that resolve instantlyAssigned seats, scarce inventory, checkout flows with a real payment form
Decision 03

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 queueReject with backoffUser-visible waiting room
What it isAn internal buffering mechanism between services -- invisible to the userFast failure with a clear retry messageA visible admission gate the user actively waits in, with position/status feedback
FairnessNone inherent -- it's an implementation detail, not a user experienceNone -- first request to retry successfully winsCan implement an explicit fairness policy (FIFO, lottery, priority tiers)
Operational complexityLow to moderateLowHighest -- admission tokens, queue state, reconnect handling
Abandonment riskN/AUser leaves and may not retryUsers wait, but a long or opaque queue still risks abandonment
Decision 04

Availability Cache Strategy

How stale can the seat map be, and what mechanism keeps it reasonably fresh? There is no single universal default TTL.

StrategyTrade-off
Fixed TTLSimple 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 changeFresher than TTL alone; adds complexity in tracking and propagating every seat-state change
Event-driven read modelThe 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/refreshLetting the client force a fresh read (e.g. right before attempting a reservation) trades a bit of load for a better-informed attempt
Decision 05

Conditional Update vs. Version vs. Row Lock

A decision matrix for picking a concurrency mechanism given the actual access pattern.

Access patternRecommended mechanism
Ordinary reservation, low-to-moderate contention per seatConditional 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/partitionConsider serialized processing for that hot key -- see Event Shard vs. Seat Partition below
Decision 06

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 seatMulti-seat, one partitionMulti-seat, spans partitions
MechanismOne conditional updateOne transaction, deterministic lock order, verify all-or-noneSaga with temporary sub-holds and compensation, or constrain bookings to one partition
ComplexityLowModerateHigh -- approaches distributed-transaction territory
Recommended defaultN/AConstrain the product so most multi-seat requests land in one partition where possibleOnly 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.

Decision 07

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 rowSeparate hold/allocation entity
SimplicitySimpler for single-seat transitions -- one row, one updateMore schema and transaction complexity
History/auditAwkward -- overwriting the seat row loses the history of who held it beforeNatural -- each hold is its own row, so history is inherent
Multi-seat modelingAwkward -- no natural place to represent 'these three seats belong to one hold'Natural -- one hold row can reference multiple seat IDs
Decision 08

Expiry Scan vs. Delayed Timer

Several mechanisms can implement durable hold expiry -- they trade precision against operational simplicity.

Swipe to pan
Hold Expiry MechanismsAll three must still condition the expiry on the hold's identity/version -- a bare state check is unsafe.Indexed DB scan(state, expires_at) indexsimple, bounded scan costDelayed queue messagescheduled at hold timeprecise, needs delay-capable queueWorkflow / timer servicedurable timer per holdmost precise, most operational surfaceCommon requirement, regardless of mechanism:the expiry transition must be conditioned on this specific hold's identity + version --never a bare "is this seat still HELD" check (see Old Expiry Task Releases a Newer Hold).
Three durable hold-expiry mechanisms trading precision against operational complexity -- an indexed DB scan, a delayed queue message, and a workflow/timer service -- all of which must still condition the expiry transition on the specific hold's identity and version.
MechanismTrade-off
Indexed DB scanSimple; scan cost is bounded by a good (state, expires_at) index and batching
Delayed queue messagePrecise timing; adds a dependency on a delay-capable queue/scheduler
Workflow/timer servicePrecise and integrated with the booking saga's own state, if one is already in use
Time-bucketed expiryGroups 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
Decision 09

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 onlyHierarchical: event -> section/block -> seat
Isolation between eventsGood -- each event's load stays on its own shardGood -- same isolation, plus finer granularity within an event
Scaling one hot eventLimited -- the whole event still lands on one shardBetter -- load for one event spreads across multiple partitions
Multi-seat atomicitySimple -- all of one event's seats share a partitionHarder when a selection spans partitions -- see Single-Seat vs. Multi-Seat Atomicity
Decision 10

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.

Decision 11

Polling vs. Push Availability

Should clients poll the seat map, or should the server push updates?

PollingPush (WebSocket/SSE)
SimplicitySimple, predictable request patternMore moving parts -- connection management, fan-out
FreshnessBounded by poll interval; can be relatively expensive at high frequencyCan feel closer to real-time
Reservation authorityNeither 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.

Decision 12

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.

Decision 13

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.

Decision 14

Direct Publish vs. Transactional Outbox

Publishing booking.confirmed directly after the DB commit is simpler, but leaves a real dual-write gap.

Direct publishTransactional outbox
Implementation costLowerHigher -- an outbox table, a relay, relay-lag monitoring
Failure windowA crash between commit and publish silently drops the eventThe event's existence is guaranteed by the same transaction that committed the booking
Consumer requirementMust still be idempotentMust still be idempotent -- at-least-once, not exactly-once
Decision 15

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 ownerMulti-region active-active
ComplexityLow -- one authoritative store decides every reservationHigh -- needs conflict prevention and write-ownership fencing per seat/partition
Risk if done naivelyN/ATwo regions can independently believe they each legitimately hold the same seat
When justifiedThe default for most ticketing platformsOnly when regional latency or availability requirements explicitly demand it, alongside a real fencing design