Cruxly › System design › Design Exercises
Seat Booking
a correctness constraint that forbids eventual consistency
Open the interactive lessonPrompt: sell reserved seats for events. A seat must never be sold twice. Users hold a seat for 10 minutes while paying. Popular events sell out in seconds.
40 minutes first. Everything else in this track has been about scale; this one is about a hard correctness constraint, and the two pull in opposite directions.
Applying the four moves: Scope — hold a seat for 10 minutes, pay, confirm, release on expiry; venue management and dynamic pricing out. Estimate — not throughput but contention: thousands of users hitting the same few hundred seats in the first seconds of an on-sale, which is the opposite shape from every scale exercise in this track. Sketch — seat state (available / held / sold) with a conditional update as the only way to claim one, and a hold carrying its own expiry. Deep dive — prove a seat can never sell twice under concurrent claims, then handle the hold that expires mid-payment. Correctness pulls against scale here, and naming that tension is the senior move.
Scope and numbers
In scope: browse availability, hold a seat, pay, confirm. Never double-sell. Out of scope: dynamic pricing, recommendations, resale, refunds. Note that resale is genuinely hard for the same reason as the core problem — say that rather than just listing it.
Estimate. The load is extraordinarily spiky, and saying so first is the framing.
- Venue: 50,000 seats. Sale opens at a fixed time.
- 2 million people arrive in the first minute. ~30,000 requests/s for one event, almost all of them browse and retry.
- Successful bookings: at most 50,000 total. Fewer than 1,000 writes/s, and it ends in a minute.
The asymmetry is the design. Read traffic is 30–100x the write traffic and most of it is doomed — 97% of arrivals get nothing. So: make reads cheap, approximate and cacheable; make the tiny write path strictly correct. Two different systems with two different guarantees, and being explicit about that split is the answer.
Why eventual consistency is disqualified here
Worth stating plainly, because most of the interview's other instincts push the wrong way.
With eventual consistency two users can both be told a seat is free and both book it. You then discover the conflict and resolve it by cancelling one — a refund, an apology, and a customer who will not come back. For a follower count this reconciliation is invisible. For a seat it is a business failure.
So seat state needs a single authority per seat and a linearizable transition from available to held. There is no clever way around this: it is a uniqueness constraint on a shared resource, which is precisely what coordination is for.
What you can do is bound the coordination:
- Partition by event. Each event's inventory is independent, so it lives entirely on one shard and a booking is a single-partition transaction. No distributed transaction, no two-phase commit, and this follows directly from a good shard key — the same lesson as the chat sequence number.
- Keep the transaction small. Only the seat-state change is coordinated. Payment, email, and the ticket PDF happen outside it.
- Make everything else eventual. The seat map users browse can be seconds stale. Nobody is harmed by seeing a seat that was just taken, as long as the attempt then fails cleanly.
Saying "I am making the consistent part as small as possible rather than making the whole system consistent" is the sentence the interviewer is waiting for.
The hold, and where the timer lives
A seat has three states: available → held(user, expires_at) → sold. The hold is what makes payment possible without blocking the seat forever.
Acquiring a hold is a conditional update, executed inside the event's partition:
UPDATE seat SET state='held', held_by=?, expires_at=now()+10min
WHERE event_id=? AND seat_id=? AND (state='available'
OR (state='held' AND expires_at < now()))
:::key This is the core of the whole design — one query Atomic, and it handles expiry as part of the same condition. Zero rows updated means someone else got it; the client is told immediately and offered alternatives.
The instinct is to reach for a distributed lock — but a lock service is a worse version of this with an extra failure mode. Say that explicitly. :::
Expiry without a sweeper. Do not run a job that scans for expired holds and frees them — it races against the acquire path, and it's the classic bug in this design. Instead, expiry is evaluated on read, as the condition above already does. A background job can tidy up lazily for reporting, but correctness never depends on it having run. This is the same lazy-expiry idea as the short-link exercise — pointing out that it recurs is a nice moment.
Clock skew. now() must be the database's clock, not an application server's, or two app servers with skewed clocks disagree about whether a hold expired. Since all of an event's writes go to one partition, there is exactly one clock that matters — another payoff from the shard key.
:::warn Payment outcomes: the one you can't guess your way out of Success: held → sold in one transaction with the payment reference. Failure or abandonment: the hold simply expires, no action needed.
Timeout with unknown payment state is the nasty one — the provider may or may not have charged. Resolve it with an idempotency key on the payment request and a reconciliation job that queries the provider for the true outcome, never by guessing.
A user charged for a seat they didn't get is the worst outcome in the system — so say explicitly that you would rather hold the seat too long than release it while a payment might be in flight. :::
Absorbing the spike
30,000 requests/s for 50,000 seats means almost all traffic is waste. Shed it early and cheaply.
Availability reads. The seat map is served from cache with a 1–2 second TTL, from the CDN or edge where possible. Stale by a second or two, which is correct: precision here is worthless because the state changes faster than the user can click. Do not let 30,000 requests/s of browsing touch the inventory partition — that would starve the writes that matter.
A virtual waiting room. For high-demand events, admit users to the purchase flow at a controlled rate. Everyone else gets a queue position that updates over a lightweight channel. This turns an unservable spike into a servable stream, and — more important than the capacity argument — it is fairer and more legible than a lottery of who has the fastest network. Say the fairness argument; it is a product judgement and interviewers notice it.
Admission tokens are signed and time-boxed, and the purchase endpoints reject anything without one. That is what stops people bypassing the room by calling the API directly.
Per-user limits. Cap seats per user per event, enforced in the same transaction as the hold (not as a separate check, which races). This is also your bot defence, together with rate limits per account and per payment instrument — and bots are the real adversary here, not load.
Idempotent requests. Users hammer refresh. A client-generated request ID on the hold endpoint means a retry returns the existing hold rather than attempting a second one. Without it a user can hold two seats by double-clicking, which is both a bug and an exploit.
Deep dives
Adjacent seats. "Four seats together" must be all-or-nothing. Inside one partition it is one transaction over four rows, which is fine. The subtlety is fragmentation: greedy allocation of pairs leaves isolated single seats that nobody buys. Mitigation is an allocation policy — prefer blocks that do not orphan a single seat — which is a product decision with real revenue impact, and naming it shows you are thinking past the mechanism.
General admission. No seat identity, just a count. Now it is a single counter and every purchase contends on one row — a worse hotspot than assigned seating. Fix by splitting the allocation into k buckets of capacity/k, sending each request to a random bucket, and rebalancing when one empties. Contention drops by k, at the cost of a small chance of reporting sold out while another bucket has stock. Worth noting that the simpler-sounding variant is the harder one.
Multi-region. An event is pinned to one region, the one where it is physically happening. Users elsewhere pay cross-region latency on the write path, and browse locally from cache. The alternative — multi-leader inventory — would require distributed consensus per seat, which is both slower and far more complex. Explaining why you rejected it is stronger than not considering it.
The inventory partition dies mid-sale. Failover must not lose acknowledged holds — a user who was told they hold seat 14 must still hold it. This requires synchronous replication for inventory specifically, even though you would use async elsewhere. Per-data-class replication settings, justified by the consistency requirement: a good place to close, because it ties the whole design back to the one constraint you started from.
Fairness and observability. Log every hold attempt with its outcome, and track the ratio of attempts to successes per user. It is how you find bots after the fact, and how you answer "was the sale fair?", which after a sold-out event someone will ask.
How to say it in the interview
"Double-selling a seat is not a degraded experience, it is a refund and a support case and possibly a legal problem. So the seat inventory has to be strongly consistent, and my job is to make the strongly consistent part as small as possible so the rest can still scale."
Checklist
- Led with the read/write asymmetry: 30k/s reads, under 1k/s writes, 97% doomed
- Said explicitly why eventual consistency is disqualified for seat state
- Partitioned by event so a booking is a single-partition transaction
- Kept the coordinated section minimal and made everything else eventual
- Acquired the hold with one conditional update that also handles expiry
- Evaluated hold expiry on read instead of running a sweeper job
- Used the database's clock, noting all writes for an event share one
- Handled unknown payment outcomes with idempotency plus reconciliation
- Served the seat map from a short-TTL cache and said stale is correct here
- Proposed a waiting room and argued it on fairness, not only capacity
- Made the hold endpoint idempotent against double-clicks
- Used synchronous replication for inventory specifically