Cruxly

Cruxly › System design › Design Exercises

Seat Booking

a correctness constraint that forbids eventual consistency

Open the interactive lesson

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

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.

FIRST MINUTE — 2M arrivals, 30,000 requests/s 97% of this traffic is doomed before it arrives 3% — 50,000 seats total read system cheap, approximate, cacheable — 30,000/s write system strictly correct — under 1,000/s, ends in a minute
The 97% failure rate isn't a flaw to fix — it's the fact that justifies building two systems with two different guarantees instead of one system trying to satisfy both.

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:

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.

EVENTUAL CONSISTENCY — disqualified replica A: free replica B: free user A books user B books conflict found LATER cancel one, refund, apologize — a business failure SINGLE AUTHORITY (chosen) one partition per event A and B both attempt — serialized only one succeeds — no reconciliation needed
The difference isn't detection versus prevention — it's whether the conflict can exist at all, even for an instant.

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.

USER A USER B SEAT 7A ROW UPDATE ... WHERE available UPDATE ... WHERE available seat 7A serializing writes… A: 1 row updated → held B: 0 rows updated → fails cleanly No lock service. No distributed coordination. One statement, and the database's own row serialization does the rest.
The condition in the WHERE clause is the entire concurrency control mechanism — the database was already going to serialize these two writes to the same row.

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.

30,000/s ARRIVE all requests virtual waiting room admitted — signed token everyone else — queue position purchase endpoints direct API call, no token rejected Fairer than a lottery of network latency — and the admission rate is tuned to what inventory can actually serve.
The token is what makes the queue enforceable rather than advisory — without it, anyone who bypasses the waiting room UI reaches the same endpoints everyone else is queued for.

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.

ONE COUNTER — the "simpler" version is the worse hotspot capacity: 10,000 every single request contends on this ONE row K BUCKETS (chosen) — contention divided by k bucket 1: 2,500 bucket 2: 2,500 bucket 3: 2,500 bucket 4: 2,500 random routing — 4x less contention per bucket Trade made explicit: a small chance of "sold out" while another bucket still has stock.
The version that sounds simpler — one counter — is the one that actually hotspots hardest; splitting capacity is the counterintuitive fix.

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

Practice this interactively
Share this lessonWhatsAppLinkedInX

Related lessons