Zenorator
Atlas of Internet-Scale Product Systems — Chapter 13

Inventory Reservation (Real-World Saga)

Inventory reservation is where distributed systems theory meets the thing that will actually lose your company money tonight.

30 min read3 figuresSee the concept map ↓
Chapter 13 · Concept map

The shape of the whole thing

5 stages, in the order the chapter argues them. Read it top to bottom, or jump straight to the part you came for.

FoundationsPatternsTrade-offsCase studiesOperating it

Introduction

Inventory reservation is where distributed systems theory meets the thing that will actually lose your company money tonight.

The saga pattern, as Chapter 12 described it, coordinates work across services without a distributed transaction. It's clean, composable, and it behaves beautifully when the things you're coordinating are abstract — sending an email, flipping a flag, appending to a log. Nobody files a ticket because they got the welcome email twice. Nobody's evening is ruined because a flag flipped forty milliseconds late.

Then you try to sell concert tickets.

Fifty seats. A hundred thousand people holding presale codes, refreshing since the on-sale time showed up on the band's site two weeks ago. The instant sales open, all of them hit submit inside the same second. You have to sell exactly fifty tickets, reject ninety-nine thousand nine hundred and fifty requests, do it in under a second each, and get it exactly right — not approximately, not statistically, exactly — because every wrong answer is either a customer who paid for a ticket they'll never receive, or two strangers holding confirmation emails for the same seat. One of them is going to find out at the turnstile.

Here's the part the textbooks skip. The challenge was never the algorithm. You can find the algorithm in fifteen minutes; it's in every system-design video on the internet. The challenge is running that algorithm under the one condition that makes it hardest, which is the condition you'll actually face: peak load arrives at the precise moment correctness matters most, and your window to be right is narrower than a single database round-trip. The interview asks you to name the pattern. Production asks you to run it on the worst night of the year, while a hundred thousand people refresh.

The concert ticket is useful framing because the stakes are legible, but the race isn't about concerts. It surfaces anywhere a scarce resource is shared under load: hotel rooms, limited-edition sneakers, appointment slots, airline seats, ICU beds. The domain changes. The race condition doesn't.

The one-line version:

you cannot compensate your way out of a double-booking. Once two people are holding boarding passes for 14A, there is no graceful undo — one of them gets bumped, and they remember it. So for scarce physical inventory, prevention is the entire game. Compensation is for everything else.

That single constraint is what separates this chapter from Chapter 12. A saga is happy to let a step fail and roll it back. Reservation has steps that, once they go wrong in front of a customer, cannot be rolled back in any sense that customer will accept. This chapter is about getting those steps right — the failure modes that stay invisible until production, the patterns that survive a Taylor Swift on-sale, and the trade-offs that turn out to matter far more than the interview version lets on.

The Problem

Strip away the concert and the shape of it is plain: sell exactly the inventory you have — not one unit more — to a crowd that all arrives in the same second, in a window too short to stop and think. And because a double-booking can't be compensated away, correct here is unforgiving in a way most systems never have to face. No retry saves you, no eventually, no apology email that un-seats the second person in 14A. The usual distributed-systems escape hatch — let it fail, we'll fix it in compensation — is bolted shut before you start.

Every approach to this lives or dies on one tiny gap: the space between checking "is there inventory?" and acting "take it."

The bug is never the check and never the act — it's the gap between them.

This is TOCTOU, Time Of Check To Time Of Use, and the gap is the room where the world changes out from under you. At 10 requests per second the gap is theoretical and you'll never see it. At 10,000 requests per second the instant sales open, you meet it in the first thirty seconds — as a spreadsheet of duplicate orders, not an exception in your logs.

The clearest way to understand what actually works is to watch the obvious moves fail, one at a time. What follows isn't three unrelated mistakes — it's a single chain. Each attempt closes the hole the previous one left open, and tears a fresh one somewhere else. Follow the chain to its end and you arrive, by process of elimination, exactly where the industry did.

The Naive Solutions and Their Obituaries

Attempt 1 — check, then charge. The obvious version: read the count, and if it's positive, charge the card and decrement. (Full code: Appendix A.1.) Every engineer writes this once, and it sails through review — because at the load review runs at (one reviewer, one request, one tidy mental model of "and then the next thing happens") it is completely correct. It's correct in a single-threaded world, which the internet is not. Under load, a hundred requests read inventory — all seeing 50 — before any of them writes 49, so all hundred charge and all hundred think they won. You oversold, and you did it silently: nothing threw, nothing alerted, you find out from refund requests, which is the most expensive monitoring money can buy. Why it doesn't scale: the gap is invisible at 10 RPS and a duplicate-order spreadsheet at 10k. It ships precisely because it looks flawless right up until the day it doesn't.

Attempt 2 — wrap the gap in a lock. Attempt 1 died in the gap, so close the gap: hold a lock across check-and-charge, and only one request is ever inside at a time. Correct! And, at concert scale, an operational disaster — though not for the reason most engineers reach for first. The reflex is "locks are slow." Locks aren't the problem. What you put inside the lock is. Here charge_card() sits inside the boundary (full code: Appendix A.2), so the lock is held for the entire payment-network round-trip — hundreds of milliseconds on a good day, whole seconds on an on-sale. A lock is a promise to be quick; charge_card() is a promise you don't get to keep, because Visa is keeping it for you. The production-grade face of the very same mistake is pessimistic row locking: SELECT ... FOR UPDATE holds the row until the transaction commits, so an external payment call inside that transaction pins the row — and a database connection — for the processor's entire response time. Teams profile it against a 5ms mock, declare victory, and ship; then the real processor has a slow morning, connection pools drain to zero, and the whole API falls over. Every dashboard points at the database, so that's where everyone looks. It was never a database problem. It was a lock-duration problem in a database costume. Why it doesn't scale: ten thousand attempts each holding a 500ms lock is two reservations per second — you've pegged your throughput to Visa's SLA, and the queue grows faster than you can drain it.

Attempt 3 — decouple: reserve fast, charge after. Attempt 2's whole problem was the lock spanning the slow payment, so take payment out of the critical section: reserve first (one fast write), charge second. The throughput problem evaporates — and a subtler one is born in its place, because the two steps can now fail independently. Payment declines, times out, or the charging service crashes between the steps, and now: who unreserves the seat? What happens if the thing that's supposed to release it dies before it does? Nothing coordinates the cleanup, so a failed payment leaves a seat reserved forever. Let those accumulate and "50 available" quietly becomes "50 phantom holds attached to nobody" — customers see sold out and leave, and you've sold nothing. You've achieved the inventory of a sold-out show with the revenue of a cancelled one. Why it doesn't scale: every failed payment leaks a seat, and failed payments are not rare. This is the exact failure sagas were invented to prevent — hold that thought.

Attempt 4 — optimistic concurrency. A cleverer swing at Attempt 1 that holds no lock at all: check, attempt the decrement, and retry if someone beat you to it. It's a genuinely good pattern — when conflicts are rare. A concert on-sale is the one workload where conflicts are the norm: every request targets the same hot seat, so every request conflicts, retries, and its retries conflict too. Latency detonates, a few win, most time out. Why it doesn't scale: it relocates the contention instead of removing it, and aims it straight at the hottest key. Right tool, wrong universe.

Line the four obituaries up and the answer falls out of them. Each attempt bought one property by surrendering another: accuracy without throughput, throughput without recoverability, cleverness without surviving contention. None is algorithmically wrong — they're wrong for a load pattern where correctness is unforgiving and the whole crowd arrives in the same second, which is a different and more humbling way to be wrong. The design the field converged on refuses to make the trade at all: pull the seat out of the pool fast and atomically (accuracy), without a lock across payment (throughput), with explicit compensation for the orphans (recoverability). That design is the subject of the rest of this chapter.

Core Patterns

Three patterns carry the load in real systems. They build on each other: two-phase reservation is the core move, the saga wraps it in orchestration, and partitioning is what lets it scale. All the code is in Appendix A — the bodies below stay in plain language on purpose, because the idea is the part worth keeping in your head.

Pattern 1

Two-Phase Reservation

The insight is that "selling a ticket" is two decisions, not one: can this customer take the seat? and did this customer actually pay for it? Pull them apart and you can run each at the speed it deserves — and, more importantly, keep the fast, contention-heavy decision well clear of the slow, fallible one.

Phase 1: Hold. A customer picks seat 14A. The system atomically flips that seat from AVAILABLE to HELD, ties it to the customer's session, and stamps it with a TTL — five minutes is the usual number, long enough to finish checkout, short enough that the abandoned ones don't pile up. The flip is a single atomic write: Redis SETNX (set if not exists), or a compare-and-swap on a row. Exactly one customer wins the hold. Everyone else asking for that seat gets an instant "unavailable" — no queue, no wait, no retry storm. The losing is fast, which turns out to matter as much as the winning.

Phase 2: Payment, then Confirm or Release. The winner checks out. The system charges the card. Payment succeeds, the seat moves HELDSOLD in the primary database. Payment fails, the hold releases and the seat goes back to AVAILABLE. And if the customer wanders off mid-checkout, the TTL expires and the seat returns to AVAILABLE on its own — no cleanup job runs, no code executes, the clock just does it.

The intuition — the hold *is* the invention.

Everything clever here lives in one move: take the seat out of the available pool before committing to a sale, using an operation fast enough and atomic enough that ten thousand simultaneous requests resolve to one winner in sub-millisecond time. The TTL is the saga's compensation for the unhappy-but-common path where the customer never comes back. You don't write code to clean up after people who got distracted looking for their wallet. You let time do it.

Figure · Two-Phase Reservation: Hold → Payment → Confirm or Release.
Surveyor’s note · figure not yet drawn(Inline figure — render here, beside this concept.) Three swimlanes: Customer, Reservation Service (Redis for hold state), Payment Service. Customer sends "reserve seat 14A." Reservation Service executes an atomic Redis SETNX — single operation, single winner. Seat state box transitions AVAILABLE → HELD, with a dotted TTL countdown running below it. Customer proceeds to checkout; Reservation Service calls Payment Service with the hold token. Two branches diverge: (1) Payment succeeds → Reservation Service writes SOLD to the primary DB, releases Redis hold, returns confirmation; (2) Payment fails → hold releases immediately, seat returns to AVAILABLE, customer notified. A third dotted path shows TTL expiry: customer abandons checkout, hold expires automatically, seat returns to AVAILABLE with no explicit action.
the hold removes the seat from the available pool without committing to a sale, makes the hold operation fast and lock-free for every requester who loses, and uses the TTL as automatic compensation for the abandoned-checkout path.

This is the model every competent ticket platform runs. You've seen its user-visible tell a hundred times: the countdown timer, "Your seats are held for 4:32." That isn't a designer's flourish. It's the TTL, surfaced — the system being honest with you about exactly how long it's willing to hold the seat before the clock takes it back.

Now the failure mode nobody writes down until it has personally cost them money: the TTL expires during legitimate payment processing. Customer grabs seats at 4:55 on the timer. Payment takes 90 seconds — not unusual when the card network is groaning under the same on-sale you are. The hold expires at 5:00. A second customer atomically claims 14A at 5:01. The first customer's payment clears at 6:25. You have now sold one seat to two people, both are holding confirmation emails, and your reservation system logged exactly zero errors the whole time, because from where it sat every individual step was correct. This is the cruelest kind of bug: it's made entirely of things working as designed.

The fix is unglamorous, which is probably why it's missing from v1. Extend the TTL when checkout begins, not just when the hold is created. Cap the extension so an abandoned session still can't squat on a seat forever. And use the hold token as an idempotency key into the payment step, so that if the hold is gone by the time the charge clears, you treat that payment as a failed reservation — refund first, and only then decide whether anyone gets a confirmation. This is the detail absent from every breezy "how ticket booking works" explainer and present in every production ticket system that has lived through a major on-sale. (Full code: Appendix A.3.)

Pattern 2

Saga-Based Reservation

Two-phase reservation handles the happy path and the clean sad path. The saga is what you build for everything in the middle — and in a system with a payment processor and a couple of network hops, the middle is surprisingly crowded.

What happens when the Reservation Service is down at the moment you try to release a hold? When the Payment Service answers with neither success nor failure but a timeout — the worst answer, because it means you don't know what happened to the money? When the confirm-write to the primary database fails after the charge has already gone through?

A saga models the reservation as a sequence of named events, each paired with an explicit compensation:

  • ReservationCreated — seat is HELD, timer starts
  • PaymentInitiated — charge submitted to the processor
  • PaymentProcessed (success) → ReservationConfirmed — seat moves to SOLD in the primary DB
  • PaymentFailedReservationReleased — hold releases, seat returns to AVAILABLE

Each step commits independently. Each has a compensation that undoes it. An orchestrator — Temporal, AWS Step Functions — tracks which steps have completed and fires the compensations when one fails. It owns the retry logic, the timeout thresholds, and the genuinely nasty case: the ambiguous outcome, where the move is to ask the payment processor what actually happened (using your original idempotency key) before deciding whether to compensate. Guessing here is how you refund a charge that never happened, or confirm a seat you never paid for.

Figure · Reservation Saga: from request to confirmation, with explicit compensation.
Surveyor’s note · figure not yet drawn(Inline figure — render here, beside this concept.) Three horizontal phases left to right, with state transitions and a compensation path. Phase 1 (Hold): customer request enters the Reservation Service; service issues SETNX to Redis per seat ID; seat transitions AVAILABLE → HELD; TTL countdown shown as a dotted timeline below the hold. Phase 2 (Payment): Reservation Service hands off to Payment Service with hold token and idempotency key; two outcomes branch — success arrow continues right, failure arrow curves back left. Phase 3 (Confirm or Release): success path writes SOLD to primary DB and deletes the Redis hold; failure path releases the Redis hold and returns the seat to AVAILABLE; TTL-expiry path also returns AVAILABLE, drawn as a dotted auto-release arc beneath the timeline. Temporal spans all three phases as an orchestrator box, tracking saga state at each step.
the hold is the fast atomic gate that prevents double-booking; each phase commits independently; the orchestrator absorbs ambiguous outcomes and retries without ever leaving a reservation stranded in limbo.

This is not a distributed transaction, and it's worth being honest about where the seam is. There's a brief window — between PaymentProcessed and ReservationConfirmed — where the seat is HELD, the money has been taken, and the confirmation hasn't yet landed in the database. That window is bounded by the time it takes one database write. The business can live with a window that small. What the business cannot live with is that same window staying open indefinitely because a service died with no recovery path and nobody noticed until the chargebacks arrived. The saga's whole job is to guarantee the window always closes. (Full code: Appendix A.4.)

There's a quieter payoff too, and principal engineers learn to love it: every in-flight reservation is inspectable. "How many reservations have been stuck in payment-pending for more than sixty seconds?" is a Temporal query you can run in the moment. Without an orchestrator, that same question is a forensic dig across three services' logs that you start after something has already gone wrong — and you have to know what you're hunting for before you have any reason to know something's wrong.

Pattern 3

Partitioning to Reduce Contention

Two-phase reservation doesn't make the lock-contention problem vanish. It shrinks it — from one lock over all inventory to one lock per seat. For a 50,000-seat stadium, that's 50,000 independent contention domains. A request for 14A doesn't fight a request for 22F; they never touch. The partition boundary lands exactly on the natural unit of scarcity, which is precisely where a partition boundary wants to be.

Figure · Partitioning: one queue for all seats vs. one atomic gate per seat.
Surveyor’s note · figure not yet drawn(Inline figure — render here, beside this concept.) Two stacked panels sharing a visual grammar. Top panel (pessimistic, single lock): a crowd of ~500 request arrows all funnel into one lock box over "ALL INVENTORY"; one passes through, the rest are drawn stacked in a waiting queue behind it, each annotated "waits for the winner's full checkout (~500ms)." Bottom panel (partitioned two-phase): the same crowd fans out to many per-seat SETNX gates; at the hot seat, one arrow wins (green, "HELD, sub-ms") and the rest bounce off instantly (grey, "unavailable — try another seat") with no queue behind them; arrows for other seats sail through their own gates untouched.
pessimistic locking serializes losers into a wait behind the winner's slowest step; partitioned holds turn losing into an instant, cheap negative — same crowd, completely different latency profile and user experience.

The contention that's left lives within a single seat. At peak, hundreds of people may be reaching for 14A at the same instant. SETNX settles it on contact: one writer wins, everyone else gets an immediate negative. The hold itself is sub-millisecond, and — this is the part that matters — the losers don't wait. They get told "no" fast enough to go pick a different seat while it's still available.

Hold that against pessimistic locking, where the losers queue up behind the winner and wait for the winner to finish their entire checkout flow before learning whether the seat was even available. Partitioned two-phase reservation turns "wait behind 499 strangers for thirty seconds to find out you lost" into "lose in a millisecond and go find another seat." Same crowd, same scarcity, completely different night.

The failure mode to watch is hotspot seats. When the artist announces their lucky seat number, or "pit" is a small distinct category, a sliver of seats soaks up a wildly disproportionate share of requests. Redis does SETNX in sub-millisecond time, but even Redis has a throughput ceiling per key, and a single screaming-hot key can hit it. The mitigation is to refuse to manufacture hotspots in your own data model: don't represent "50 general-admission tickets" as one counter you decrement. That's one key, fifty buyers, and a Redis node doing the job of a turnstile. Represent them as 50 independent seat IDs even when the customer never picks a specific spot — fifty cool keys beat one molten one.

Pattern 4

Pattern Comparison

The one thing this table can't show is that these three aren't rivals. They stack.

Pattern What it actually solves Reach for it when When you can skip it
Two-Phase Reservation (hold, then pay) The core race — taking inventory out of the pool without holding a lock across the payment round-trip Scarce inventory plus a slow or external payment step. This is the base layer almost everything else sits on. Only when there's no real contention, or no slow step to protect — i.e., when you didn't have the problem to begin with
Saga-Based Reservation (orchestrated compensation) The ambiguous middle — timeouts, crashes, and partial state between hold and confirm — plus in-flight reservations you can actually query Payments can fail or time out ambiguously and you need guaranteed cleanup instead of a hopeful cron job Low volume and low stakes, where a nightly sweep for stuck holds is cheaper to run than an orchestrator is to operate
Partitioning (one atomic gate per item) Throughput under concentrated load — thousands of requests converging on the same or neighboring inventory Peak traffic hammers a small set of items (on-sales, drops, "pit") and per-item throughput is the ceiling you keep hitting Traffic is low or evenly spread — no hotspots means nothing to partition away

Read it as a staircase, not a menu. You start with the hold, because without it you don't have a correct system to begin with. You add the saga the day "a service died mid-payment" stops being hypothetical and becomes a thing you have to survive on purpose. You add partitioning when the load stops being polite. A neighborhood yoga studio booking ten mats needs the first row and can cheerfully ignore the other two; a stadium on-sale needs all three and a waiting room on top. The whole skill is adding each layer exactly when the problem earns it — not before it does (that's the startup malpractice from the trade-offs section), and not during the incident that proves you needed it.

Tradeoffs

The central tension is accuracy versus throughput, and the thing to internalize is that getting it wrong is expensive in the specific direction you got it wrong. There's no symmetric, safe failure here.

Pessimistic (lock everything, never oversell) is accurate, correct, and slow under load, with lock-hold duration setting the throughput ceiling. It's the right call when overselling is severe and unrecoverable — a double-booked hotel room costs the room, the relationship, and the one-star review with your brand name in the title. At that cost, correctness is worth the throughput hit, and the slowness is just a capacity problem you can throw hardware at, within reason and within budget.

Optimistic with holds (may occasionally double-sell in a genuine edge case) is fast, scalable, and wrong every so often — when something like TTL-during-payment slips through. It's the right call when overselling is bounded and recovery is cheap: for most e-commerce an oversell is an apology email and a refund, annoying rather than catastrophic. You're choosing to serve 10,000 requests per second and clean up the rare miss by hand, over serving 20 requests per second flawlessly and losing the sale to a competitor whose checkout didn't time out.

Intentional oversell with compensation is the option engineers rarely architect for on purpose but plenty of businesses already run on purpose. Airlines oversell economy by a known margin because the no-show rate is reliable, and on the rare day everyone shows up they buy a few people off with vouchers. The compensation is product policy, not a bug — a line item, not an incident. It's optimal when the statistics hold and the payout is cheap against the revenue of never flying empty seats. It turns catastrophic the moment the statistics are wrong, or the thing you oversold can't be compensated with a voucher — a hospital operating room, say, instead of a middle seat to Cleveland.

The architecture has to follow the business policy, not the other way around.

Decide what your oversell tolerance actually is, in dollars and in reputation, first. Then pick the technical model that enforces it. Engineers who pick the concurrency model before anyone's had the business conversation reliably over-build for accuracy where nobody needed it, or — worse, and quieter — under-build for accuracy where the company genuinely couldn't afford to be wrong. Both mistakes look identical in the design doc. Only one of them pages you.

Company Examples

BookMyShow

BookMyShow runs concert, film, and sports ticketing across India — tens of millions of users, events that sell out in seconds, and a user base that is extremely motivated. An Arijit Singh on-sale is not a traffic ramp. It's a step function: effectively zero to millions of requests inside three seconds, the kind of curve that looks like a typo on a graph.

Their design maps cleanly onto the two-phase model. Seat selection takes a hold in Redis — one key per seat, one SETNX per attempt, a 10-minute TTL surfaced as the countdown timer. The acquisition is atomic: one customer wins, everyone else gets "unavailable" in sub-millisecond time. They treat the speed of the negative as a first-class feature, and they're right to — a customer who hears "no" instantly goes and grabs another seat instead of sitting on a spinner, which means fewer retries, which means less load, which means more customers hear a fast answer. Speed of rejection is load-shedding disguised as courtesy.

Checkout triggers payment; payment confirmation writes the finalized sale to the primary database; Redis TTLs sweep up the abandoned sessions with no background jobs, no manual cleanup, no "why is this seat stuck" runbook.

Here's the failure mode that cost their engineers actual sleep, and it's a good one because per-seat partitioning is supposed to save you from it. During a major film's first-day-first-show sale, millions of requests for a small number of seat categories hit a Redis cluster that was correctly partitioned by seat — and fell over anyway, because the session-setup and TTL operations all landed on the same handful of cluster nodes. No single key was hot. The nodes were hot. The partitioning was right and the bottleneck moved somewhere the partitioning didn't cover, which is how scaling problems usually go: you fix the obvious chokepoint and the load calmly walks over to the next one. Their fix was pre-seeding seat state before the on-sale, regional Redis sharding by event ID, and — the telling decision — deliberately letting the "X seats left" counter shown to users go stale. The counter and the reservation system are different concerns, and the expensive mistake is conflating them. The counter can be eventually consistent and a little wrong; nobody gets bumped from a show because a number was off by three. The hold cannot be wrong, ever. They spent their consistency budget where the consequences actually live.

Ticketmaster

Ticketmaster's most famous contribution to this whole subject is the virtual waiting room — the purgatory you sit in before the sale opens, watching a progress bar tell you you're "in the queue" with no evidence you're moving. It is not a reservation mechanism. It's a demand leveler, and it's doing more work than its reputation suggests.

The waiting room controls the rate at which reservation requests are allowed into the system. Instead of 500,000 requests arriving in the same second, the reservation service sees 5,000 — which is a tractable problem that standard two-phase reservation handles in its sleep. People dunk on the waiting room because it looks like theater, and sometimes, when it's implemented badly, it is. But the problem it solves is real: a system that handles 5,000 concurrent reservations correctly is orders of magnitude simpler than one that handles 500,000, and the cheapest way to build the second system is to put a waiting room in front of the first one.

The thing worth saying flatly is that the two pieces solve two different problems and you need both. The waiting room is a load shedder. The reservation system is a correctness guarantor. Run only the reservation system at full unleveled load and you have the correctness problem from the top of this chapter. Run only the waiting room without fixing the reservation system and you've built the appearance of control with none of the substance — users wait politely in line, advance to the front, and then the reservation system double-sells anyway, because the race condition was never the line's job to fix. The queue makes the failure look orderly. It doesn't make it go away.

Inside the reservation system it's hold-then-pay, same as everyone else — holds in Redis, confirmations in the primary database, Temporal mopping up the ambiguous outcomes. Where Ticketmaster has spent its engineering money is the orchestration layer: payment timeouts at scale, the double-submission case (the user who opens two tabs, or hits back and resubmits, and would very much like to be charged once), and making sure a payment-processor wobble during a major on-sale degrades gracefully instead of corrupting data on the way down.

Technologies

Redis handles Phase 1 in most production builds. SETNX gives you atomic hold acquisition — one writer wins, everyone else gets an instant negative, no queue. EXPIRE sets the TTL. Sub-millisecond latency means the hold isn't a throughput bottleneck under normal conditions.

The caveat is the one people skip past: Redis, in its default config, is not durable. An AOF-flushing Redis can lose the last second of holds in a crash. For most ticketing that's fine — holds are short-lived working state, not the system of record, and a lost hold just frees a seat that wasn't sold yet. If you genuinely cannot tolerate losing a hold, you put database-backed holds behind a Redis cache, accept the extra latency and complexity, and get durability you can actually lean on. Most teams don't need that, and the ones who think they do usually haven't priced the latency.

PostgreSQL or MySQL handles Phases 2 and 3. The confirmed sale is a financial record — it needs durability, transactions, and a schema that survives an audit and a reconciliation. The orders or seat_sales table is the source of truth. Redis is working memory for the hold phase, and you should be able to lose all of it without losing a single sale.

Temporal (or AWS Step Functions, or Airflow for the simpler workflows) orchestrates the saga. Each reservation becomes a Temporal workflow — a durable code object that survives process restarts, retries with configurable backoff, and runs compensations when a step fails. The operational benefit is the one from the saga section, and it's worth repeating because it's why you tolerate the extra moving part: saga state is explicit and queryable. "How many reservations have been in payment-pending more than sixty seconds?" is a query, not an archaeology project.

The Principal Engineer's Questions

The Principal Engineer’s View

The patterns above are the solved part — the part you can look up. These are the questions that don't have a lookup, the ones that actually land on a principal engineer's desk.

When is overselling acceptable? More often than engineers want it to be, and the math is not subtle. A retailer selling limited-edition sneakers absorbs the odd oversell with a refund and a small buffer. Expected loss from a 0.1% oversell rate on a $150 item across 10,000 orders is about $150. The cost of building and maintaining zero-oversell infrastructure is measured in engineer-weeks and ongoing operational drag. That arithmetic almost always says: accept the rare oversell, pocket the engineer-weeks. Save the full correctness machinery for the cases where compensation genuinely isn't on the table — the same seat at a live event (two people, one chair, physics is unimpressed by your apology email), finite medical resources, financial instruments where a double-sell is a legal liability rather than a support ticket. The domain tells you which world you're in. The job is to say which world out loud before you start choosing locks, because the most expensive version of this mistake is building the wrong tolerance by accident and discovering your assumption in a postmortem.

Where does the complexity actually live? Not in the hold — the hold is one Redis command. It lives in the edge cases, every time: the TTL expiring mid-payment, the double-submit from a refreshed page, the processor timeout where you don't know if the money moved, the chargeback that lands three days after the seat got resold. Each one needs real handling in the saga — a timeout path, an idempotency check, a query-then-decide. And here's the part that should change how you read a design: the systems that handle these badly are almost never the ones that got the algorithm wrong. They're the ones that got the algorithm right and waved off the edge cases as too rare to bother with. They are rare. They are also precisely what your customers remember, what the press writes up, and what pages your on-call at 2 AM. Rare and unimportant are not the same word, and conflating them is a senior-engineer mistake, not a junior one.

What is the organizational cost? A Temporal saga with Redis holds and Postgres confirmations is three systems to run, monitor, back up, and get paged for. Redis replication lag becomes a correctness concern on top of a latency one. Temporal's own datastore needs capacity planning and backups. A schema change in the hold layer now has to be coordinated with the confirmation layer. That weight compounds across a team over years. For a startup moving 500 tickets a month, standing all this up is engineering malpractice — use a database row and a pessimistic lock, ship it, go find customers. For a platform moving 5 million tickets a year across concurrent events, it's table stakes and the row-and-a-lock would be the malpractice. The skill isn't knowing the fancy architecture. It's the honest assessment of which company you actually work for — not which one looks better in the design doc, and not the one you wish you worked for.

What changes at international scale? The version that looked tractable in one region gets meaningfully harder once it's geographically distributed. The same seat may want region-local Redis for latency while the confirmed sale lands in a globally consistent primary database, and the TTL logic suddenly has to reason about clock skew across regions — the same gremlin from the idempotency and rate-limiting chapters, back for another chapter, because it always comes back. A network partition between the Redis region and the database region opens failure modes that simply don't exist single-region. This is where the conversation stops being "this worked in staging" and starts involving conflict-free replicated data types and consistency arguments you can actually defend on a whiteboard. If that sounds like a lot, it is — which is exactly why "do we truly need multi-region reservation, or just multi-region reads?" is a question worth asking before, not after.

Exercises

None of these has a clean answer — that's the point. Drop any of them into an AI and you'll get a confident, well-organized response in four seconds that reads beautifully and quietly skips the only things that matter: your traffic shape, your blast radius, your tolerance for being wrong. The value is in the argument you have with yourself first.

  1. A hotel booking system holds a room for 30 minutes during checkout. Payment averages 45 seconds but occasionally blows past 30 minutes during banking delays. Design a TTL strategy that keeps the hold alive through legitimate slow payments without letting an abandoned session squat on the room forever. Then change the problem: how does your answer move if the hotel takes payment at check-in instead of at booking?
  1. You're building reservation for a regional blood bank allocating rare blood types to hospitals. It must never oversell, and it has to survive network partitions between the central inventory and the regional hospitals. What consistency model do you pick, and which failure modes are you knowingly signing up for? Now swap the product for concert tickets and watch which of your answers you're suddenly willing to abandon — that's the real lesson.
  1. A ride-sharing platform holds a driver for a rider for 45 seconds during trip acceptance; no acceptance in 45 seconds and the hold releases and the rider is rematched. Model it as a saga: what events does it emit, what compensations exist? Then handle the genuinely mean case — the driver accepts at second 44, and the network confirmation arrives at second 46, after the hold released and another driver was already matched. Who's driving? How do you make sure it's exactly one of them?

Connections

← Chapter 12 (Saga Pattern): This is the saga pattern dropped into a domain where the stakes are physical and the edge cases page people. Chapter 12 built the framework in the abstract; this chapter runs it against fifty concert seats and a hundred thousand motivated strangers to show why each piece of the framework had to exist.

→ Chapter 14 (Matching Systems): Matching systems — Uber's driver assignment, DoorDash's courier routing — run reservation logic at their core. "Matching" a driver to a ride is reserving that driver's availability, with a timeout if they don't respond and a compensation (rematch) if they decline. The patterns are the same ones from this chapter; what changes is that the real-time constraints get tighter, the inventory (available drivers) is in constant motion instead of fixed at 50,000 seats, and the compensation runs in a continuous loop rather than a one-shot checkout. Chapter 14 is what inventory reservation looks like when it stops being the checkout step and becomes the entire product.

The intuition to carry out of this chapter: reservation is the one corner of distributed systems where "we'll fix it in compensation" stops being true. You can apologize for almost anything after the fact — a duplicate email, a late webhook, even a double-charge you can refund. You cannot un-seat the second person in 14A once the house lights go down. So the whole discipline collapses to a single move, made early and made fast: pull the seat out of the pool the instant someone reaches for it, before any slow or fallible thing happens, and let a clock clean up the ones who wander off. The saga, the orchestrator, the partitioning, the tiered consistency — all of it is bookkeeping wrapped around that one move. Get the hold right and everything else is engineering. Get it wrong and everything else is a refund queue with your name on it.

Appendix A: Reference Implementations

The chapter keeps the code out of the narrative on purpose. Here it is, collected and annotated — read the prose for the idea, come here when you want to type it out. The first two entries are the naive versions, included precisely so you can see where they break; the two after are the shapes that survive an on-sale. All of it is illustrative rather than production-hardened: no auth, no metrics, no error taxonomy, and a fistful of edges you'll meet the first time you run it for real.

A.1 — Check-then-charge, no locking (the overselling trap)

Diagram3 lines
1if inventory > 0:
2 charge_card()
3 inventory--

The check and the decrement are two operations with a gap between them, and at scale every concurrent request reads the same 50 inside that gap before any of them has written 49. Correct in a single-threaded world, catastrophic the moment two requests overlap. This is the version that ships, because it passes every test that runs one request at a time — which is every test anyone writes before their first oversell.

A.2 — Lock around check-and-charge (correct, throughput disaster)

Diagram4 lines
1with db_lock("seat_14A"):
2 if inventory > 0:
3 charge_card()
4 inventory--

This closes the TOCTOU gap and is genuinely correct. It's also a throughput disaster, because charge_card() sits inside the lock — so the lock is held for the entire payment-network round-trip, and your per-seat ceiling becomes one reservation per card-network latency (a few per second, when you needed thousands). The fix isn't a faster lock. It's not holding a lock across a network call you don't control — which is the whole reason two-phase reservation exists.

A.3 — Atomic hold with Redis SETNX, TTL extension on checkout, and safe release

Python70 lines
1import redis
2import uuid
3from typing import Optional
4 
5r = redis.Redis(decode_responses=True)
6 
7def hold_seat(seat_id: str, session_id: str, ttl_seconds: int = 300) -> bool:
8 """
9 Atomically hold a seat for a session. Returns True if hold acquired,
10 False if seat is already held or sold.
11 SET key value EX ttl NX — atomic: set only if key does not exist.
12 """
13 key = f"seat:{seat_id}:hold"
14 return r.set(key, session_id, ex=ttl_seconds, nx=True) is True
15 
16def extend_hold(seat_id: str, session_id: str, new_ttl_seconds: int = 600) -> bool:
17 """
18 Extend TTL when checkout begins. Only extends if this session owns the hold.
19 Uses a Lua script for atomic check-and-extend — avoids extending another
20 session's hold if ours expired between the GET and the EXPIRE.
21 """
22 key = f"seat:{seat_id}:hold"
23 lua = """
24 if redis.call('GET', KEYS[1]) == ARGV[1] then
25 return redis.call('EXPIRE', KEYS[1], ARGV[2])
26 else
27 return 0
28 end
29 """
30 return r.eval(lua, 1, key, session_id, new_ttl_seconds) == 1
31 
32def release_hold(seat_id: str, session_id: str) -> bool:
33 """
34 Release a hold. Only releases if this session owns the hold.
35 Critical: without the Lua check-and-delete, a race between GET and DEL
36 can delete another session's hold that was legitimately acquired after ours expired.
37 """
38 key = f"seat:{seat_id}:hold"
39 lua = """
40 if redis.call('GET', KEYS[1]) == ARGV[1] then
41 return redis.call('DEL', KEYS[1])
42 else
43 return 0
44 end
45 """
46 return r.eval(lua, 1, key, session_id) == 1
47 
48def confirm_sale(seat_id: str, session_id: str, db_conn, charge_id: str) -> bool:
49 """
50 Confirm a hold as a completed sale. Verifies the hold is still active
51 before writing to the primary DB. If the hold has expired (TTL race),
52 returns False — the caller should refund the charge rather than confirm.
53 """
54 current_holder = r.get(f"seat:{seat_id}:hold")
55 if current_holder != session_id:
56 # Hold expired or claimed by another session — payment must be refunded
57 return False
58 
59 db_conn.execute(
60 """
61 INSERT INTO seat_sales (seat_id, session_id, charge_id, sold_at)
62 VALUES (?, ?, ?, NOW())
63 """,
64 (seat_id, session_id, charge_id)
65 )
66 db_conn.commit()
67 
68 # Hold is now superseded by the durable DB record
69 release_hold(seat_id, session_id)
70 return True

The Lua scripts in extend_hold and release_hold are not optional — plain GET-then-EXPIRE or GET-then-DEL sequences are race conditions. The check-and-act has to be atomic, which is what Lua gives you in Redis. The confirm_sale function is the TTL-during-payment edge case handler: if the hold is gone when payment completes, the function returns False and the caller issues a refund rather than a confirmation.

A.4 — Temporal workflow as saga orchestrator

Python79 lines
1from temporalio import workflow, activity
2from temporalio.common import RetryPolicy
3from datetime import timedelta
4 
5@workflow.defn
6class ReservationSaga:
7 """
8 Orchestrates the three-phase reservation saga:
9 1. Hold seat (Redis, fast)
10 2. Charge payment (external, slow, may ambiguously fail)
11 3. Confirm sale (DB write) or compensate (release hold, refund)
12 
13 Temporal handles crash recovery: if the process dies between phases,
14 the workflow resumes from the last committed step on restart.
15 """
16 
17 @workflow.run
18 async def run(self, seat_id: str, session_id: str,
19 customer_id: str, amount_cents: int) -> dict:
20 
21 # Phase 1: Hold the seat
22 hold_acquired = await workflow.execute_activity(
23 hold_seat_activity,
24 args=[seat_id, session_id],
25 start_to_close_timeout=timedelta(seconds=5),
26 )
27 if not hold_acquired:
28 return {"status": "REJECTED", "reason": "seat_unavailable"}
29 
30 # Extend hold now that checkout is beginning
31 await workflow.execute_activity(
32 extend_hold_activity,
33 args=[seat_id, session_id],
34 start_to_close_timeout=timedelta(seconds=5),
35 )
36 
37 # Phase 2: Charge payment
38 # Idempotency key = workflow ID, so retries don't double-charge
39 try:
40 charge = await workflow.execute_activity(
41 charge_payment_activity,
42 args=[customer_id, amount_cents, workflow.info().workflow_id],
43 start_to_close_timeout=timedelta(seconds=120),
44 retry_policy=RetryPolicy(
45 maximum_attempts=3,
46 initial_interval=timedelta(seconds=2),
47 backoff_coefficient=2.0,
48 ),
49 )
50 except Exception as e:
51 # Compensation: release the hold before returning failure
52 await workflow.execute_activity(
53 release_hold_activity,
54 args=[seat_id, session_id],
55 start_to_close_timeout=timedelta(seconds=10),
56 )
57 return {"status": "FAILED", "reason": "payment_failed", "detail": str(e)}
58 
59 # Phase 3: Confirm the sale in primary DB
60 confirmed = await workflow.execute_activity(
61 confirm_sale_activity,
62 args=[seat_id, session_id, charge["charge_id"]],
63 start_to_close_timeout=timedelta(seconds=10),
64 )
65 
66 if not confirmed:
67 # TTL expired during payment — refund and return failure
68 await workflow.execute_activity(
69 refund_charge_activity,
70 args=[charge["charge_id"]],
71 start_to_close_timeout=timedelta(seconds=30),
72 )
73 return {"status": "FAILED", "reason": "hold_expired_during_payment"}
74 
75 return {
76 "status": "CONFIRMED",
77 "seat_id": seat_id,
78 "charge_id": charge["charge_id"],
79 }

The workflow.info().workflow_id as payment idempotency key is the detail that prevents double-charges when Temporal retries the payment activity after a timeout. Each unique reservation attempt gets one workflow ID; the payment processor deduplicates on it. The confirm_sale_activity returns False when the hold expired during payment, which triggers the refund path rather than sending the customer a confirmation email for a seat they don't actually have.

Next: Chapter 14 — Matching Systems: reservation logic when the inventory moves and the loop never stops.