Ticket booking — a full worked answer
The whole thing, start to finish, in the order you would actually say it. Read it to learn the shape of a strong answer — then go and produce one yourself, because reading is not the same skill as producing.
The question
Design a system for booking seats at events. Seats are specific and numbered. Popular events sell out in under a minute, with far more people trying than there are seats. Nobody may ever end up with a seat somebody else also has.
Contested inventory. A fixed number of things and more people who want them than there are things.
If two people can end up with the same one, you have failed, no matter how fast the system is. Caching the availability number is the classic wrong turn.
Before anything: where is the slack?
How long the system has before a person notices. Ask this before you draw a single box — it decides more of the design than scale does.
Almost everything here has generous slack. One operation has none at all, and that operation is the problem.
Browsing has minutes of slack. The event listing, the seat map and the price can all be cached hard and served stale. They must be, because at on-sale time a hundred thousand people load that page in ten seconds. A seat map that is five seconds out of date is fine; someone clicks a seat that was just taken and is told so at the next step.
The hold has minutes of slack by design. You give the user 10 minutes to enter card details, and that window is itself a decision about how much inventory you are willing to make unavailable to other people.
The commit has zero slack and zero tolerance for being wrong. At the instant a seat is assigned, the decision must be exact, serialised, and durable. No eventual consistency, no cache, no approximation. If two commits succeed for one seat, you have sold something twice and no amount of speed makes up for it.
Everything after (the confirmation email, the ticket PDF, the analytics, the reconciliation with the venue) has minutes or hours of slack and belongs on a queue.
So: The slack profile splits the system cleanly in two: a massively cacheable read tier that can be stale, and a small, strictly serialised commit path that cannot. The skill is keeping the second one small. Weak designs let the strict path grow.
Requirements
about 5 minWhy this stage exists: Because the question is deliberately vague, and every box you draw before this is a guess.
Assumptions: global, but each event belongs to one venue with a fixed, known number of seats: typically thousands, not millions. Traffic is extremely spiky. An event goes on sale at a fixed moment and receives most of its demand in about sixty seconds. Payments happen through a provider. Reads massively outnumber writes even during a sale, because most people are looking rather than committing.
First question, which decides how much locking I need: are seats specific and numbered, or is it general admission where only the count matters? Numbered seats mean the contended resource is one row per seat and uniqueness is naturally enforceable; general admission means a single counter that everyone contends on, which is a harder concurrency problem. I will assume numbered seats, as the prompt says.
Second question: do we hold a seat while the user pays, and for how long? A hold makes inventory unavailable to everybody else, so the window length is a direct business tradeoff. It also introduces expiry, which is a whole subsystem. I will assume yes, 10 minutes.
Scope: browse events and seat availability, hold a seat, pay, confirm. Out of scope: pricing and dynamic pricing, seat recommendations, refunds and resale, and fraud. I will note, though, that a sale like this attracts bots, and the queueing mechanism is partly an anti-bot control.
What an interviewer is ticking off in this stage
- Stated the traffic shape as extremely spiky, not steady
- Asked numbered seats versus general admission and said why it changes the concurrency model
- Asked about the hold and its duration, and framed it as a business tradeoff
- Noted that reads dominate even during a sale
- Proposed a scope and excluded pricing, refunds and resale
Actors and lifecycle
about 6 minWhy this stage exists: Because the happy path is the easy half. The failure branches are where the design actually lives.
Actors: the buyer, the event organiser who defines inventory, the payment provider, and the system, which decides who gets into the sale, when a hold expires, and what to do about a payment that lands after a hold has already lapsed.
Track the seat, not the user, because the seat is the contested resource. Its life: available → held → paid → confirmed → possibly released back to available.
Failure branches, which are the design. At held: two users try to hold the same seat in the same millisecond. One must succeed, enforced by a conditional update or a unique constraint, never by check-then-write. At held: the user abandons the checkout, so the hold must expire, and the mechanism matters. A background job sweeping for expired holds can lag. I prefer holds that carry an expiry timestamp checked on read, so an expired hold is released at once even if the sweeper is behind. At held: the hold expires while the user is mid-payment. This is the nastiest branch, because their money is about to move for a seat they no longer have.
At paid: the payment succeeds and the confirmation write fails. This is the classic distributed transaction failure, and the answer is ordering plus reconciliation: authorise the payment while the hold is valid, confirm the seat, then capture. If the confirmation cannot be written, void the authorisation, which is far cheaper and cleaner than refunding a completed charge.
At paid: the payment provider times out and the client retries. Without an idempotency key, that is two charges. At confirmed: the event is cancelled, so every seat needs a bulk release-and-refund path.
And one the system must spot on its own: a user who holds seats, never pays, and does it repeatedly at scale is a bot denying inventory to real buyers. Holds need per-user limits, which is a design requirement, not a policy afterthought.
What an interviewer is ticking off in this stage
- Tracked the seat rather than the user
- Wrote the seat lifecycle as a chain
- Handled two simultaneous holds with a conditional write, not check-then-write
- Handled hold expiry, and preferred timestamp-checked expiry over a sweeper alone
- Handled the hold expiring during payment
- Handled payment succeeding while confirmation fails, with ordering and reconciliation
- Handled duplicate payment attempts via idempotency
- Noted the bot-holding-inventory problem
The lifecycle, drawn
Numbers
about 6 minWhy this stage exists: Because one part of this is hard and the rest is easy, and they look identical until you do the arithmetic.
The shape is the story. A 50,000-seat event with 500,000 people trying to buy in the first minute. That is not "high traffic", it is a wall: roughly 100,000 requests per second arriving in a burst against a system whose steady state is a few hundred.
Look at what those requests are. The large majority are page loads and availability checks, which are reads. Actual hold attempts might be 20,000 per second at the very start, and successful claims are capped at 50,000 total, ever, for this event, because that is how many seats exist. The contended write path processes at most 50,000 operations in the entire sale.
That number changes the design. The write path is not a throughput problem: 50,000 writes in total is nothing. It is a contention problem: 20,000 attempts a second aimed at a few thousand rows that are rapidly being taken. Almost every one of those attempts must fail, quickly and cheaply.
Reads: 100,000 requests a second for mostly identical data (the event page, the seat map). That is close to an ideal caching problem, because everyone wants the same bytes at the same moment.
Storage: 50,000 seats per event at a couple of hundred bytes is 10 MB per event. Even a hundred thousand stadium-sized events a year is about a terabyte, and most venues are far smaller. Storage is not a concern.
So the hard part here is contention, not volume: rejecting the ~90% of attempts that must fail without letting them touch the database at all. The second hard part is keeping the small exact part exact under that pressure.
What an interviewer is ticking off in this stage
- Described the traffic as a burst, not a rate
- Separated read volume from contended write volume
- Noticed total successful writes are bounded by the seat count
- Concluded this is a contention problem, not a throughput problem
- Noted storage is irrelevant here
- Finished the sentence naming contention and cheap rejection
The numbers, drawn
Only 50,000 writes will ever succeed, because there are only 50,000 seats. So the hard part is not write throughput. It is rejecting the ~90% of attempts that must fail, cheaply, before they reach the database.
High-level design
about 12 minWhy this stage exists: Because now — and only now — you have a reason for every box you are about to draw.
The first move is drawing the line between the stale tier and the exact tier, and keeping the exact tier as small as possible. Everything about browsing (event pages, seat maps, prices) is served from a CDN and a cache with a short TTL. Justified by the read numbers: everyone requests identical bytes simultaneously, so the hit rate is near perfect. The seat map being a few seconds stale is fine and I would say so explicitly, because a user clicking a taken seat is a normal, cheap rejection.
A waiting room in front of the sale. At on-sale, users are given a queue position and admitted at a controlled rate. This component does more than any other here. It converts an uncontrolled 100,000-per-second burst into a steady, known rate the booking path can serve. It makes the experience honest (a position and an estimate rather than an error). And it is the main anti-bot control. Justified directly by the burst shape from Stage 3.
The claim itself, which must be exact. Seats live as rows in a relational database, one row per seat, with a status and a hold expiry. Claiming is a single conditional update: set this seat to held by me with an expiry, where the seat is currently available or its hold has expired. If it updates one row, I have it; if zero rows, someone else does. That one statement is the correctness guarantee. It is atomic in the database, so there is no window between checking and taking. I would say plainly that I am not doing a read then a write in application code, because that is the race itself.
Justification for a relational database here: the contended write volume is tiny (bounded by the seat count), so I do not need a distributed store, and I do need a real transaction to tie the seat to the order. Here the numbers permit the simple, correct choice, and I would say so rather than reach for something distributed.
Partitioning by event: seats for one event live together, so a hot event is one partition and other events are unaffected. Justified by blast radius: a sold-out stadium should not slow down everything else on the platform.
Payment as a saga, ordered on purpose: hold the seat, authorise the card, confirm the seat, capture the payment. Authorise-then-capture matters because voiding an authorisation is cheap and clean, while refunding a capture is slow, visible to the customer, and sometimes costs a fee. Every step carries an idempotency key.
After confirmation, everything else goes on a queue: the email, the ticket generation, the analytics, the organiser reporting. Justified by the slack analysis: none of it needs the user waiting.
Hold expiry: each hold row carries an expiry timestamp, and the claim query treats an expired hold as available. So expiry takes effect immediately at the point it matters, and a background sweeper only tidies up; it is not on the correctness path. That distinction is worth stating. A design that depends on a sweeper running on time has a window where seats are wrongly unavailable.
What an interviewer is ticking off in this stage
- Drew the line between the cacheable stale tier and the exact tier, and kept the exact tier small
- Included a waiting room and justified it by the burst shape
- Claim is a single conditional update, explicitly not check-then-write
- Justified a relational store using the bounded write volume
- Partitioned by event to contain blast radius
- Payment ordered as authorise → confirm → capture, with a reason
- Idempotency keys on the payment path
- Post-confirmation work moved to a queue
- Expiry checked in the claim query, not dependent on a sweeper
The architecture, drawn
Deep dive and tradeoffs
about 14 minWhy this stage exists: Because naming what a decision costs is the single clearest signal of seniority there is.
Hard part one: contention on the same rows. Twenty thousand people want the front row. The conditional update means 19,999 of them get zero rows updated, which is correct but means the database is processing 20,000 write attempts against a handful of rows, all serialising on the same locks. Several things help. The waiting room is the biggest: it caps the arrival rate, so this is thousands rather than tens of thousands. Making "best available seat" the default flow, rather than seat picking, spreads attempts across the inventory instead of concentrating them. And I can reject early: if the cached view says a seat has been taken, reject before the database, accepting that this is only a signal and the database remains the authority. Cost: early rejection can occasionally reject a seat that just became available again through an expiry, so it must never be the only check.
On distributed locks, since interviewers often push here: I do not need one, and I would say why. A distributed lock is for coordinating across systems that have no shared point of serialisation. I have one, the database row, and a conditional update on it is atomic, faster, and cannot suffer the failure mode where a lock holder pauses, its lease expires, and two workers believe they hold it. Adding a lock on top would be strictly worse: more moving parts, one more thing to fail, and no additional guarantee. Choosing the simpler mechanism and explaining the rejection is worth more than demonstrating I know what Redlock is.
Hard part two: the payment boundary, in detail. Authorise while the hold is valid. If the authorisation is slow and the hold lapses mid-payment, do not silently extend it. Try to re-claim the seat, and if it has gone, void the authorisation and tell the user clearly that their seat was released and they have not been charged. That is a bad experience, so I would shorten it: extend the hold once when payment begins, since the user has demonstrated intent. Cost: a slightly longer worst-case hold, and a small amount of inventory held by people who ultimately fail to pay.
If the payment succeeds and the confirmation write fails, the reconciliation job is what saves you: it compares authorisations against confirmed orders and either completes the order — using the payment reference as the idempotency key — or voids. Cost: a background job to build and monitor, and a few customers in an ambiguous state for minutes. At volume some of those cases will happen; the choice is whether you find them or the customer does.
Fairness, which is a real requirement here and easy to forget: the queue must be first-come-first-served in an observable way, or users believe it is rigged, and for high-demand events that becomes a public problem. Cost: strict ordering in the queue is more expensive than approximate ordering, and it is worth paying for on this product specifically.
Bots: the waiting room, per-account hold limits, and payment verification do most of the work. Cost: legitimate buyers occasionally get blocked, so there must be a support path. A false positive here is a furious customer who missed the sale.
Consistency per feature, stated separately: the seat map is eventually consistent and visibly a few seconds stale. The claim is strictly serialised. The order record is strongly consistent and durable. The organiser dashboard is eventually consistent and minutes behind, which is fine.
What I would monitor: hold-to-purchase conversion, the rate of claim attempts that fail because the seat is gone, expired holds per minute, and the count of orders in an ambiguous payment state. That last number tells you whether the saga is working.
What an interviewer is ticking off in this stage
- Addressed same-row contention with several specific mitigations
- Explicitly rejected a distributed lock and explained why the database row is better here
- Walked through hold expiry during payment and chose a specific behaviour
- Reconciliation job for the payment-succeeded-confirmation-failed case
- Named fairness as a requirement, not a nicety
- Named the bot problem and the false-positive cost of blocking
- Per-feature consistency answer
- Named what to monitor, including ambiguous-state orders
- Cost named after every decision
The decision this turns on
- —One statement: set held where still available. Atomic, no gap between check and write.
- —The database is already a single point of serialisation — use it.
- —No lease to expire, so no scenario where two holders both believe they won.
- —Write ceiling per partition, which the seat count guarantees you never reach.
- —Familiar pattern, and the right tool when there is no shared serialisation point.
- —Adds a component that can fail, on the most correctness-critical path you have.
- —A paused process can lose its lease without noticing — two holders, one seat.
- —Needs fencing tokens to be safe, which is more machinery than the alternative.
And then they push back
A complete answer is where the interview starts, not where it ends. These are the questions this design invites — the interview is decided here far more often than in the diagram.
Two users click at the same millisecond. What happens?
"We would use a lock." Which lock, held where, and what happens if the holder pauses? A lock is a mechanism, and reaching for it before asking whether the resource is contested at all is a reflex rather than an answer.
"First I would ask whether the thing is contested. If it is a like button, both writes land, the count converges, and I would choose not to spend a transaction on it. If it is the last seat, exactly one must win, and I get that by making the decision at a single point of serialisation: a conditional update that only succeeds if the seat is still free, or a unique constraint so the second insert simply fails. I am not proposing to read and then write in application code, because that gap is the race itself. The loser gets a clear message and an alternative, not an error. Cost: that path is now strongly consistent, so it is slower and it becomes unavailable if the owning partition is unreachable, which I accept, because selling one seat twice is worse than an error message. I would specifically not add a distributed lock, because the database row is already a single point of serialisation and a lock adds a failure mode where a paused holder's lease expires and two processes both think they won."
30x traffic for four hours on sale day. Same design?
"We would autoscale." Autoscaling takes minutes and this spike arrives in seconds. Also, the thing that breaks is usually the part that cannot autoscale, which is why naming autoscaling as the whole answer is a tell.
"Mostly the same design, but three things change. First, this spike is predictable (the date is known), so I pre-scale rather than relying on autoscaling, which reacts over minutes while the spike arrives in seconds. Second, the stateless tiers scale out, but the database and any shared counter do not, so I would protect them: a queue or waiting room in front to convert a burst into a rate, and aggressive caching of everything that does not need to be exact. Third, I would decide in advance what to shed (recommendations, analytics, non-essential enrichment) so degradation is a choice rather than a surprise. And I would load test at 30x before the day, because the failure I have not thought of is the one that matters."
The cache just died. What happens?
"Requests would go to the database instead, so it would be a bit slower." At the ratios that justify a cache, the database cannot take that load. This answer means you added a cache without checking what it was protecting you from.
"Every read that was being served from memory lands on the database at once. My cache was absorbing something like 99% of reads, so the database sees roughly 100x its normal load, and it will not survive that. A naive design goes down. What I would do about it, designed in advance: coalesce misses so a thousand simultaneous requests for the same key produce one database read rather than a thousand; keep a small in-process cache on each server as a second layer so the hottest keys never leave the machine; shed load at the edge rather than let the database die, because a degraded site beats a dead one; and bring the cache back warm rather than empty, because restarting into an empty cache reproduces the outage. Cost of the in-process layer: servers can briefly disagree with each other."
Why this database and not Postgres?
"It scales better and is designed for high throughput." Vague and unfalsifiable. Postgres handles far more than most people assume, and an answer with no number in it sounds like a preference.
"The answer has to be a number or an access pattern. Something like: the write rate is around 80,000 a second sustained on one logical table, append-only, with no cross-row transactions. That is more than I would plan to commit on one Postgres primary, so I would be sharding it myself and hand-building rebalancing and failover that a distributed store already has. The read pattern is a single-key lookup, so I am giving up joins I do not need. And then what it costs: no cross-partition transactions, so any invariant spanning partitions moves into application code, and analytics needs a separate copy of the data. If I cannot produce a sentence like that, then the correct answer is that Postgres is fine. Saying so is the stronger answer: choosing the boring option on purpose shows more judgement than reaching for the distributed one."
The write worked but the response was lost and the client retried. What happens?
"The client would get an error and could try again." They already tried again. That is the premise. If the retry does the work a second time, you have charged the card twice.
"With idempotency keys, nothing bad: the retry carries the same key, the server finds it already claimed, and returns the original stored response, so the client sees success and the card is charged once. Without them, two charges and a customer complaint. The implementation detail that makes it airtight is inserting the key with a unique constraint in the same transaction as the work, because two separate steps leave a crash window between them. Two things people get wrong: the client must reuse the key across retries rather than generating a fresh one each attempt, which defeats the scheme without raising any error; and if the retry arrives while the first request is still running, I return an in-progress status so the client backs off, rather than doing the work again or returning a wrong answer. Keys expire after a day or so, or the table grows forever."
How do you deploy a new version with no downtime?
"Rolling deploy behind the load balancer." Correct and incomplete. The hard part is database changes and in-flight requests, and stopping there suggests you have only done it on a stateless service.
"Rolling deploy, taking instances out of the pool, draining in-flight requests (skip connection draining and you drop live traffic during a zero-downtime deploy), then updating, waiting for health checks, and putting them back, keeping enough capacity in the pool throughout. The part that makes it work is that the new version must tolerate the old version running beside it, because for several minutes both are live. That means database migrations get split: add the new column, deploy code that writes both and reads the old, backfill, deploy code that reads the new, then remove the old column. That is three or four releases instead of one. Cost: every schema change is now a multi-deploy sequence, which is slower and is the price of not taking downtime. And I would rather roll forward to a known-good build than roll back, because undoing a migration is much harder than undoing code."
What does this cost per month?
"I am not sure, it depends on the provider." Interviewers rarely want exact prices. They want to know whether you understand which line item dominates, and that is answerable from your own estimates.
"I would not guess at prices, but I can tell you the shape, which is what matters. The dominant cost here is egress bandwidth: my estimate said 1,350 GB a second to viewers, and at that volume nothing else on the bill comes close. Second is storage, because I keep raw data for two years and it grows every day whether anyone reads it or not. Third is compute, which is elastic and therefore the easiest to control. That ordering tells me where to spend engineering effort: a percentage point of CDN hit rate is worth more than any amount of compute optimisation, so cache hit rate is the number I would put on a dashboard and defend. Storage cost I would attack with lifecycle policies moving cold data to cheaper tiers and with tiered granularity. Compute I would run on interruptible capacity because the work has hours of slack."
Now it must be privacy compliant — users can demand deletion of everything about them.
"We would delete their row from the users table." Their data is in caches, replicas, backups, logs, analytics, search indexes and immutable event archives. One row is the easy part.
"The hard part is that data has been copied everywhere by design. I would inventory it: primary store, replicas, caches, search indexes, the raw event archive, backups, logs, and any third party I forwarded it to. Deleting from the primary is trivial; the archive is the hard one, because immutable columnar files cannot have a row removed cheaply. Two practical approaches: crypto-shredding, where each user's identifiers are encrypted with a per-user key and deleting the key makes the data unreadable, which is what makes this tractable at scale; or periodic compaction that rewrites partitions with deleted users removed, which is expensive but complete. For backups, deletion usually cannot be immediate, so the policy is that backups expire within a stated window and deletion is applied on restore. What I would change on day one knowing this: keep identifying data in as few places as possible, reference it by id everywhere else, and never copy raw identifiers into derived stores. That one rule turns this from an archaeology project into a manageable one."
The topics this answer used
You have read a good answer. That is worth very little until you can produce one on a blank page — so go and write this problem without looking, and compare afterwards.