Design ticket booking
At 10:00 the tickets for a stadium tour go on sale and a million fans press "Buy" for 50,000 seats. Most of the time a booking site is quiet; for a few minutes it faces a stampede where every request wants the same rows. Two promises matter: never sell a seat twice, and stay up long enough to sell the rest fairly. It follows the interview framework phase by phase.
1. The prompt
"Design Ticketmaster: users find events, view a seat map, pick seats, pay, and receive tickets. Popular events sell out in minutes."
The interesting part is the few minutes of an on-sale: correctness under extreme contention on a small set of rows, and a front door that doesn't fall over. If you've done D24 flash-sale checkout, this is its cousin with named seats instead of identical units.
2. Frame the problem
Assigned seats or general admission?
Assigned seats, picked on a map; best-available as an option.
Named seats mean contention on individual rows, not on a counter.
How big is the worst on-sale?
1M fans in the first minute for 50,000 seats.
20 people per seat: most must be told "no" gracefully.
How long can a user hold seats while paying?
About 10 minutes.
Holds need an expiry, and the hold length is a fairness lever.
Is fairness a requirement?
Yes: first in line, first served, and bots shouldn't win.
That points to a queue in front of the booking system.
A ticket limit per person?
Up to 6 per account per event.
A check inside the same transaction as the hold.
Who handles payment?
A payment provider; we call it.
Idempotent payment calls and a clean hold-to-sold step.
3. What it must do
- A user can search and view events.
- A user can see available seats and hold some while paying.
- A user can pay and receive tickets.
Not now:
- A resale marketplace.
- Dynamic pricing.
- Venue management.
4. How well it must do it
| Target | Why this number |
|---|---|
| No double booking, ever consistency | Two people at one seat on concert night is the failure that makes the news. |
| Survive on-sale spikes: 1M users in a minute, in a fair order load shape | The site must stay up exactly when it matters most. |
| Fast browsing: event pages under ~200 ms speed | Browsing outnumbers buying a hundredfold. |
| Unpaid holds return within minutes availability of stock | Seats locked by people who walked away are lost sales and angry fans. |
5. Numbers that change the design
| Quantity | Working | → so we… |
|---|---|---|
| Normal traffic | Say 10M page views a day | ~120 a second → so cache event pages; easy |
| On-sale spike | 1M fans in the first minute, each refreshing and retrying | Tens of thousands of requests a second for one event → so queue them before they reach booking |
| Contention | 50,000 seats; the best few thousand wanted by almost everyone | Hot rows → so atomic conditional writes, short transactions |
| Booking throughput | 50,000 seats in ~10 minutes | ~85 bookings a second → so one well-tuned database is enough if the crowd is metered |
| Seat map reads | Thousands of admitted fans refreshing every few seconds | Thousands of reads a second for one event → so serve the map from a cache, not the database |
6. The nouns
- Event: venue, time, details.
- Seat: per event, with a status.
- Booking: a user's claim on some seats.
- Queue ticket: a fan's place in the waiting room.
events event_id, venue_id, starts_at, name, on_sale_at
seats (event_id, seat_id) → section, row, number, price, status: available | held | sold,
booking_id, hold_expires_at (partitioned by event_id)
bookings booking_id, user_id, event_id, seat_ids, status: pending | confirmed | expired,
payment_id, created_at
queue (event_id, position) → user_id, admitted_at, token
7. The contract
GET /v1/events?query=… → search results
GET /v1/events/{id} → details (cached)
GET /v1/events/{id}/seats → seat map (cached for a second or two)
POST /v1/bookings {event_id, seat_ids} → 201 {booking_id, hold_expires_at} | 409 seats taken
POST /v1/bookings/{id}/pay {payment_token} Idempotency-Key: … → confirmed
The user comes from the session, never the body. During an on-sale, booking calls also need a valid waiting-room token.
Data flow: (1) fans join the waiting room → (2) admitted at a set rate with a token → (3) view the cached seat map → (4) hold seats with a conditional update → (5) pay → (6) confirm: seats sold, booking confirmed.
8. First working version
A web service and one database with events, seats and bookings tables.
8.1 Browsing
Event pages and search read from the database; the seat map lists seat statuses for the event.
8.2 Booking
Version one reads the seat, checks it's available, then writes the booking. Run the simulator in section 13 to see how often that sells a seat twice.
8.3 Paying
Charge the card, then mark the seats sold.
Parked for later (section 9 fixes each)
- Read-then-write lets two fans buy the same seat.
- Seats are sold before payment finishes, or locked forever if it never does.
- A million fans hit the booking service at 10:00.
- Thousands of fans refreshing the seat map against the database.
- A retried payment can charge twice.
9. Make it hold up
9.1 Never selling a seat twice no double booking
| Approach | Why | |
|---|---|---|
| ✗ Bad | Read the seat; if available, write the booking | Two users read "available" a millisecond apart and both write: a double booking. Run the simulator in section 13. |
| ~ Good | A distributed lock per seat (for example in Redis) while booking | Works, but adds a second system whose failure modes (expired lock, network partition) must be reasoned about; the database is still the truth. |
| ✓ Great | An atomic conditional update in the database: UPDATE seats SET status='held', booking_id=…, hold_expires_at=now()+10min WHERE event_id=… AND seat_id IN (…) AND (status='available' OR (status='held' AND hold_expires_at < now())); succeed only if every row updated | The database makes the check and the write one step, so exactly one request wins. Holding several seats in one transaction keeps a group together (all or nothing). |
9.2 Holding seats during payment unpaid holds return
Paying takes a minute or two, so the seat is held with an expiry (say 10 minutes) instead of sold. If payment succeeds, the booking and the seats become confirmed in one transaction, which must still check that the hold is theirs. If the user leaves, the hold simply expires: the conditional update treats an expired hold as available, so no cleanup job is needed for correctness (a sweeper can tidy rows for display).
One edge needs care: a payment that succeeds just after the hold expired and someone else took the seat. The confirm step finds the hold isn't theirs, and the system refunds automatically. Keep the hold a little longer than the payment page's own timeout so this is rare.
9.3 Surviving the on-sale stampede survive spikes
- Virtual waiting room: before the sale, users join a queue (often at the CDN edge). At on-sale they are admitted in order at the rate the booking system can serve, each with a signed token valid for a few minutes. Everyone else sees their position, not an error page.
- Why it helps correctness too: admitted users see a fresh seat map and compete with a few hundred people, not a million, so far fewer clicks fail with "seat just taken". It also stops bots from simply out-refreshing people.
- Protect the rest: rate-limit per user and per token (Design D02), shed load with clear messages, and keep the browsing side cached so it stays up.
9.4 Seat maps and search fast browsing
The seat map is read constantly and changes constantly during a sale. Serve it from a cache refreshed every second or two (slightly stale is fine, because the conditional update is the real check), or push updates over Server-Sent Events. Event search goes to a search engine fed from the events database by change data capture; event pages are cached at the CDN.
9.5 Paying exactly once no double booking
Payment calls carry an idempotency key derived from the booking id, so a retried request (a double click, a timeout) never charges twice; the provider returns the first result. If the confirm step fails after the charge succeeded, a background job reconciles: it finds charged-but-pending bookings and confirms or refunds them (Design D16).
9.6 Partitioning and contention survive spikes
Partition seat data by event so a hot event doesn't slow others. One huge event still lives on one partition, which is fine at ~85 bookings a second; split by section if it isn't. Offering best available (the server assigns seats) instead of pick-your-seat spreads demand across rows and cuts conflicts sharply.
9.7 The whole design after the deep dives
One fan's on-sale, from the queue to a confirmed ticket.
10. Interviewer follow-ups
How do you pick the admission rate?
From the booking path's measured capacity (database writes, payment provider limits), with headroom. Adjust it live: lower when error rates or latency rise, higher when seats are going slowly.
Bots buy everything.
Randomise the order of people who joined before on-sale, verify accounts, apply per-account and per-card limits, and use bot detection at the waiting room. The queue removes the speed advantage.
What if the database for the hot event fails mid-sale?
Fail over to a synchronous replica (no lost holds); pause admission from the waiting room while it does, so fans wait in line instead of seeing errors.
Group of six wants seats together.
Hold all six in one transaction, all or nothing. With best-available, the server searches for a block of six adjacent free seats.
Show seats disappearing live.
Push seat-status changes over Server-Sent Events from a pub/sub channel per event; batch updates every few hundred milliseconds to keep messages manageable.
Resale.
A new booking state (listed), with transfer as another conditional update from one owner to another, and the original ticket invalidated.
What isolation level?
The conditional update is safe at the default read committed level in PostgreSQL, because the WHERE clause is re-checked on the locked row. You don't need serializable for this pattern.
Variation: booking a stay for several nights
A hotel or holiday-rental version ("book listing 88 from 12 to 15 March") changes what can clash. A seat is one thing; a stay is a range of nights, and two bookings clash when their ranges overlap, even if they start on different days.
| Approach | How it works | Verdict |
|---|---|---|
| Check, then insert | Query for an overlapping booking; if none, insert one. | Two requests can both check before either inserts, and both succeed. |
| One row per night | Insert one row per (listing, night) with that pair as the primary key, all in one transaction. | Any shared night fails the whole transaction. Simple, works on any database, and the rows double as the calendar. |
| A range constraint | Store the stay as a date range and let the database forbid overlapping ranges for the same listing (PostgreSQL's exclusion constraint). | One row per booking and the rule lives in the schema. Ties you to a database that supports it. |
The rest of the seat design carries over: a short hold while the guest pays, released when it expires, and idempotent payment. Two things are new. Hosts may approve requests, so a request holds the nights for, say, 24 hours until the host answers. And hosts also list the same place on other sites; calendars imported from those sites are hours old, so a clash is still possible. Have a clear policy for it (cancel the later booking, rehouse the guest, charge the host).
Read more: PostgreSQL's documentation on exclusion constraints.
11. When things break
| Failure | What users see | How we detect it | What limits the damage |
|---|---|---|---|
| Booking DB slow | Slow holds | Write latency | Lower the admission rate; fans wait in line |
| Payment provider down | Can't pay | Error rate | Extend holds; show "retry"; no seats lost |
| Seat-map cache stale | More "seat just taken" | 409 rate | The conditional update stays correct |
| Waiting room fails | Errors at the door | Edge health | Static "waiting" page; admission paused rather than open floodgates |
| Charged but confirm failed | "Processing" | Reconciliation job | Confirm or refund automatically |
12. Wrap-up and the clock
Trade-offs we made:
- Hold length: long holds are kind to slow payers but lock seats away from others during the rush; short holds frustrate people mid-payment.
- Pick-your-seat vs best-available: picking creates contention on the same seats; assigning spreads the load.
- A waiting room: fair and protective, at the cost of making people wait.
- The database as the only judge: caches may be stale; the conditional update never is.
What we'd watch:
- Double bookings (must be zero).
- 409 rate.
- Hold-to-sold conversion.
- Admission rate and queue length.
- Booking write p99.
At 10×:
- Partition hot events by section.
- Best-available by default.
- A longer queue rather than a faster door.
A 45-minute interview
| 0–4 | Frame the problem (seats, on-sale size, holds) |
| 4–9 | Must do, how well, numbers |
| 9–12 | Nouns, contract, data flow |
| 12–18 | First working version, parked list |
| 18–40 | Deep dives: no double booking (9.1), holds (9.2), the stampede (9.3) |
| 40–45 | Follow-ups and wrap-up |
A 60-minute interview
| 0–5 | Frame the problem |
| 5–11 | Must do, how well, numbers |
| 11–15 | Nouns, contract, data flow |
| 15–23 | First working version |
| 23–50 | Deep dives 9.1–9.6 and the final picture |
| 50–60 | Follow-ups: admission rate, bots, failover; wrap-up |
| Level | What a strong answer shows |
|---|---|
| Mid-level | Events, seats and bookings tables; a transaction to book; a cache for event pages. |
| Senior | The read-then-write race and the conditional update fix, holds with expiry, idempotent payment, a waiting room for spikes, cached seat maps with the database as the real check. |
| Staff+ | Capacity planning for on-sale, admission rate tuned to database throughput, bot defence and fairness, partitioning by event and section, failure handling between payment and confirmation. |
Common mistakes:
- Check-then-write booking.
- Selling seats before payment, or holding them forever.
- Trusting the cached seat map.
- No plan for the on-sale crowd.
- Non-idempotent payment calls.
13. Try it, check yourself, read more
100 seats, fans arriving in a 10-second burst. Each fan picks one of the best seats that look free on their (possibly stale) seat map and tries to book it, retrying up to three times. Red seats were sold twice.
The waiting room admits 25 fans a second, who see a seat map 0.3 s old; without it, everyone arrives at once and sees a map about 3 s old. A booking's check and write are 50 ms apart.
What prevents two users booking the same seat?
A user holds two seats, then closes the browser. What happens?
What does a virtual waiting room do?
Why is a seat map a second or two stale acceptable?
| Resource | Why |
|---|---|
| Transaction isolation (PostgreSQL docs) | What each isolation level does and doesn't prevent, including how updates re-check rows. |
| Waiting Room (Cloudflare docs) | How an edge waiting room admits users at a set rate. |
| Design Ticketmaster (Hello Interview) | Another walkthrough. |