Courses / System Design Interview / Chapter 36
Module 3 · Design Real Products · Chapter 36 of 39
Design a Ticket Booking System
Builds on Abstractions That Hide the Network, Databases: Replication, Partitioning and Choosing a Store, Content Delivery Networks, Distributed Caches, Message Queues, Rate Limiters, Distributed Task Schedulers, Sharded Counters and Design a Notification System. Next up: Design a Payment System.
Part 1 · Requirements and Scale
The brief
Noon on sale day. Ten lakh fans tap Book for a stadium concert, and the ticketing site stalls for a minute, then comes back. Two weeks later, at gate 4, Asha and Ravi show tickets for the same seat, B-12. Both paid ₹3,000 for it. Ravi is turned away, and his refund takes a week. The servers were not too small. Two requests read B-12 as free a few milliseconds apart, and both wrote "yours". Selling each seat exactly once, to a crowd 25 times the size of the stadium, is the problem this chapter solves.
You are building the ticketing site for big events. The next concert puts 40,000 seats on sale at noon, and about 10 lakh fans are waiting. Fans pick seats on a map, hold them while they pay, and receive a QR-code ticket. One order runs through every page: Asha's pair of seats, B-12 and B-13, and this time B-12 must be sold once.
More fans than seats
Asha's double booking came from the shape of the demand, so start there. Far more people want seats than there are seats, they all arrive in the same few seconds, and every seat can be sold only once. A railway booking site lives the same morning every day, when next-day tickets open at 10 am and huge numbers of travellers tap Book together.
The site must stay up through the rush, be fair about who gets in, and never let a payment and a seat drift apart. The table turns that into requirements, with the two promises the design may never break.
| Requirement | |
|---|---|
| Functional | Browse a show and see a seat map with free and taken seats |
| Functional | Pick up to 4 seats and hold them for 10 minutes while checking out |
| Functional | Pay, then get a confirmed booking with a QR-code ticket |
| Functional | If payment fails or time runs out, the seats go back on sale |
| Functional | For a big sale, wait in a fair queue before choosing seats |
| Non-functional | Never sell a seat twice; never keep money without issuing a ticket |
| Non-functional | Survive a rush of 10 lakh fans by making them wait, not by failing |
| Non-functional | A hold answers in under half a second; the seat map may lag by a second or two |
Most rows are ordinary product features. The first non-functional row is the hard one: selling a seat once while 10 lakh people try at the same moment.
Organiser tools, dynamic pricing, cancellations, resale and search. One twist changes the data model: a train berth can be sold from Delhi to Kanpur and again from Kanpur to Howrah, so railway inventory is stretches of a route, not whole seats. The core design uses numbered seats at one venue; the Going further part shows the segment version.
How big the rush gets
How much load does the rush create, and where does it land: on storage, on bookings or on the front door? Assume the site sells about 2 lakh tickets a day across 5,000 shows, and that Asha's show puts 40,000 seats in front of 10 lakh fans at noon.
Try it first: at noon, 10 lakh fans each send one request every 2 seconds, and the 40,000 seats sell in pairs over about 15 minutes. How many requests a second arrive, and how many bookings a second succeed?
- Requests: 10,00,000 fans ÷ 2 s = 500,000 a second.
- Bookings: 40,000 seats ÷ 2 = 20,000 bookings; 20,000 ÷ 900 s ≈ 22 a second.
- So roughly 20,000 requests arrive for every booking that succeeds.
Here is the full picture, with an ordinary day for contrast.
| Quantity | Working | Result |
|---|---|---|
| Bookings on an ordinary day | 2 lakh tickets ÷ 2 per booking ÷ 86,400 s | ≈ 1.2 per second |
| Requests at noon if every fan is let in | 10 lakh fans × one request every 2 s | ≈ 500,000 /s |
| Bookings the big show can hold | 40,000 seats ÷ 2 per booking | 20,000 |
| Bookings per second while it sells | 20,000 over about 15 minutes | ≈ 22 /s |
| Seat rows for the big show | 40,000 × about 100 B | ≈ 4 MB |
| Bookings stored per year | 1 lakh a day × 365 × about 1 KB | ≈ 37 GB |
Half a million requests a second chase about 22 bookings a second. Only about 2 fans in 100 leave with a ticket, so most of the work is telling the rest quickly, fairly and cheaply. The numbers settle four design questions:
- Storage is not the problem. One show is 4 MB, and a year of bookings fits on one machine.
- The rush is. Half a million requests a second must never reach the database, so a waiting room lets fans in at the pace the booking path can serve.
- Sharding does not spread the rush. Every fan wants the same show, so its writes land on one partition. Scattering one show's seats across machines would turn many two-seat bookings into cross-machine transactions.
- Reads far outnumber writes. Fans view the seat map many times per hold, so the map comes from a cache (the distributed cache chapter).
Part 2 · API, Data and the Big Picture
The API
The numbers say the booking path must be small and exact. Asha's journey defines it: look at the seats, hold two, check out, pay on the payment gateway's page, and fetch the ticket. During a big sale, two waiting-room calls come first.
GET /v1/events/e_9/seatmap -> 200 { version: 812, layoutUrl, free: "<5 KB bitmap>" } POST /v1/events/e_9/queue -> 200 { queueToken, position: 4213 } GET /v1/events/e_9/queue/status -> 200 { position: 4213, admitted: false } -> 200 { admitted: true, accessToken } POST /v1/events/e_9/holds { seats: ["B-12", "B-13"] } Idempotency-Key: 6f1c... -> 201 { holdId: "h_77", expiresAt: "12:10:00" } -> 409 { taken: ["B-13"] } DELETE /v1/holds/h_77 -> 204 (give the seats back) POST /v1/holds/h_77/checkout -> 201 { bookingId: "b_501", paymentUrl } POST /v1/payments/callback { bookingId, status, gatewayRef, signature } (sent by the gateway) -> 200 GET /v1/bookings/b_501 -> 200 { status: "confirmed", seats, ticketUrl }
In a queued sale, holds and checkout must carry the access token. The callback comes from the payment gateway's servers, never from the fan's phone.
Three rules sit behind those endpoints.
- A hold is not a booking. It reserves seats for 10 minutes; only a confirmed payment makes it a booking.
- Holds are all or nothing. If either seat of a pair is taken, the answer is
409 Conflictwith the taken seats, so nobody gets half a pair. - Retries are safe. The app creates one idempotency key per tap of Hold and resends it on every retry, so a timeout cannot create two holds (see the abstractions chapter). A hold can have only one booking, so checkout is safe too.
The data model
Behind those endpoints, which data must be exact and which may be approximate? Everything that must stay correct lives in one relational database: seats, holds and bookings. Each seat of each show is a row, and that row's status is the single source of truth about who has the seat.
event_id (pk)venue_idnamestarts_aton_sale_atmax_per_orderevent_id (pk)seat_id (pk)sectionpricestatus: available, held, paying or bookedhold_idhold_expires_atbooking_idhold_id (pk)event_iduser_idstatusexpires_atidempotency_key (unique)booking_id (pk)event_iduser_idhold_id (unique)amountstatus: pending, confirmed or failedgateway_ref (unique)created_at- seats event_id → events
- holds event_id → events
- seats hold_id → holds
- seats booking_id → bookings
- bookings hold_id → holds
Partition by event_id, the show's id (the databases chapter covers partitioning). All of one show's seats, holds and bookings then sit together, so holding four seats or confirming a booking is a transaction on one machine. The price is that one show cannot spread across machines, but our biggest show is 4 MB and a few hundred writes a second, which one leader handles comfortably.
The rest of the data has looser needs, so each piece lives where its reads are cheapest.
| Data | Where it lives | Why there |
|---|---|---|
| Seats, holds, bookings | A relational database, partitioned by event, with a synchronous follower | Multi-row transactions; a confirmed booking must survive a crash |
| Seat map for display | A cache: one bit per seat, free or taken, updated as seats change and rebuilt every second | Read by every shopper every few seconds; 40,000 seats ≈ 5 KB |
| Layouts, images, the waiting room page | The CDN | The same for every fan and unchanged during a sale |
| Queue positions and the admission counter | An in-memory store | Tens of thousands of tiny reads and increments a second |
| "My tickets" for each fan | A copy keyed by user_id, filled from booking events | Keeps the busiest read after a sale off the hot partition |
The seat map is a display: a second old is fine, because it only helps fans choose. The seat row is a promise: every change is a transaction on the partition's leader. That is the abstractions chapter's rule: the map can be stale, the booking check cannot.
The high-level design
With the data placed, follow Asha's tap through the whole system. It has a read side that may be slightly stale and a write side that must be exact: show pages, the seat map and queue status come from the CDN and caches, while holds, checkout and payment results pass through a stateless booking service to the show's partition.
Watch for the token: only fans the waiting room admits can reach the booking service
Read it as steps
Asha gets in, holds two seats and pays; a failed payment and an abandoned hold put seats back on sale.
The waiting room decided how many fans reach the booking service at once; the database decided which one gets each seat. Seats were held before payment, booked only after the gateway's signed callback, and freed when a payment failed or a deadline passed.
Each box has one job, and the next part opens the hard ones:
- Waiting room: places in line, a set admission rate, and an access token checked on every booking call. Its pages are static files on the CDN (the CDN chapter).
- Booking service: stateless, so more servers can be added before a big sale.
- Inventory database: the source of truth, with a synchronous follower in another zone ready to take over.
- Seat map cache: every hold and release flips bits in a 5 KB bitmap per show (the distributed cache chapter).
- Hold expiry: a delayed task per hold, as in the task scheduler chapter.
- Ticket worker: the confirming transaction writes an outbox row, a relay puts it on a queue (the message queue chapter), and a worker makes the QR ticket.
Part 3 · Deep Dives
Two people, one seat
This is the heart of the design: stopping two fans from buying the same seat is the job this product exists to do.
The scene above gave two seats to one fan. Now give one seat to two fans at once. The obvious way to hold a seat is to read the row, check that it is free, then write it. That is a check-then-act race: between the read and the write, another request can see the same free seat. At noon, many requests aim at the same front-row seats within milliseconds, so this race is the normal case, not an edge case.
Watch Asha and Ravi tap Hold on B-12 at the same moment, first with that naive code, then with a conditional update: a single statement that changes the row only if the seat is still free.
Predict before you watch: with the naive code, both servers read B-12 before either writes. What is each fan told?
- Both are told the seat is theirs.
- The row keeps whichever write landed last, so one fan has been promised a seat the row no longer gives them.
Watch for the gap between read and write. Asha and Ravi tap Hold on B-12 together
Read it as steps
Asha and Ravi tap Hold on B-12 together: the naive read-then-write tells both yes, a conditional update tells one.
The naive version failed because the check and the write were two steps with a gap between them. The conditional update closes the gap: the database does both under one row lock, and the count of changed rows says who won.
The conditional update
Why did Ravi's update find the seat held? The first update locks the row until its transaction ends, so the second waits. At the default isolation level of common relational databases, it then re-checks its condition against the committed row, finds the seat held and changes nothing. Under stricter isolation, some databases fail it with an error instead, and its retry finds the seat held. The check and the write must be one atomic step, and the count of changed rows tells you who won. Here is Asha's hold for her pair.
BEGIN; INSERT INTO holds (hold_id, event_id, user_id, expires_at, idempotency_key) VALUES ('h_77', 'e_9', 'u_42', now() + interval '10 minutes', '6f1c...'); UPDATE seats SET status = 'held', hold_id = 'h_77', hold_expires_at = now() + interval '10 minutes' WHERE event_id = 'e_9' AND seat_id IN ('B-12', 'B-13') AND (status = 'available' OR (status = 'held' AND hold_expires_at < now())); -- 2 rows changed: COMMIT, answer 201 with h_77 -- fewer changed: ROLLBACK, answer 409 with the taken seats
PostgreSQL syntax. A retry with the same idempotency key fails the unique insert and gets the existing hold back. The last condition lets a fan take a seat whose hold has expired before any clean-up job runs.
The same pattern guards every later step, which settles the race between a slow fan and the clock. Checkout moves seats from held to paying only WHERE hold_id = 'h_77' AND status = 'held' AND hold_expires_at > now(). A checkout that arrives a second after the deadline, or after another fan has taken the expired seats, changes nothing, and the fan is told the hold has ended. The expiry task runs the mirror-image update, so whichever reaches the row first wins and the other matches nothing.
Locking the row first, comparing version numbers and claiming seats in an in-memory store are the other common guards; Going further compares them. First, though: how long should a hold last?
Holds that expire
Asha now has a hold. How long may she keep B-12 and B-13 while she pays? A hold is a promise with a deadline: these seats are yours until 12:10. It is stored as a status and an expiry time on the seat rows, never as a database lock kept open while the fan types card details. A lock held for minutes would block every other request on those rows, and thousands of shoppers would need thousands of open transactions.
Each seat row moves through four statuses.
| Seat status | Meaning | Moves to |
|---|---|---|
| available | Anyone may hold it | held |
| held | Reserved by one hold for 10 minutes | paying at checkout; available if the fan gives up or time runs out |
| paying | The fan is on the payment page | booked when the gateway confirms; available only after the gateway reports a final failure |
| booked | Paid for | Stays booked; cancellations are out of scope |
Only three things free a seat: the fan giving up, the hold running out, or a final payment failure.
Hold length is a real trade-off. Suppose 1 in 5 admitted fans holds two seats and then walks away. At the waiting room's 2,000 admissions a minute, that is 800 seats a minute caught in ghost holds, and with 10-minute holds about 8,000 seats, a fifth of the stadium, are off sale at any moment. Five-minute holds halve that, but slower fans lose their seats before they reach checkout.
Four habits keep holds honest:
- Lazy expiry for correctness. The hold query treats an expired hold as free, so no seat stays stuck even if clean-up jobs are down.
- Active release for visibility. A delayed task at each deadline (the task scheduler chapter) runs
UPDATE seats SET status = 'available', hold_id = NULL WHERE hold_id = 'h_77' AND status = 'held' AND hold_expires_at <= now()and refreshes the map. If the fan is already paying, it matches nothing. - Give back early. A fan who leaves or taps Change seats deletes the hold, and the seats return at once.
- Limit holds per fan. One active hold per account per show, at most 4 seats, so a bot cannot sit on hundreds of seats.
Compare deadlines with the database's clock, now() inside the query, not each server's clock. Server clocks can disagree by seconds, enough to free a hold early.
The virtual waiting room
Holds and conditional updates keep the booking path correct, but no database survives half a million requests a second. A virtual waiting room keeps the rush away from it. Think of the token counter at a busy bank branch: you take a number, sit down, and go to the desk when your number shows. Before the sale, fans wait on a static page from the CDN. At noon each fan gets a numbered place, and one counter, admitted_up_to, rises at a controlled rate; a fan whose number falls below it receives an access token and enters the booking pages.
- 1Joina signed queue token with the fan's place; one per account
- 2Waitthe page checks every 20 seconds; the answer is one cached number
- 3Get inplace below admitted_up_to: a 20-minute access token tied to the account
- 4Shopevery hold and checkout call carries the token, and calls without one are turned away
- 5Leavebooked, timed out or sold out: the slot goes to the next fan
Asha's place is 4,213, so at 2,000 admissions a minute she is in at about 12:02. Here is the arithmetic of the dial:
| Quantity | Working | Result |
|---|---|---|
| Admission rate | Set from load tests, adjusted live | 2,000 fans a minute |
| Shoppers inside at once | 2,000 a minute × about 5 minutes each | ≈ 10,000 |
| Requests reaching booking | 10,000 shoppers × one every 3 s | ≈ 3,300 /s |
| Status checks from the line | 10 lakh fans × one every 20 s | ≈ 50,000 /s, each one cheap |
| Time to sell out | 20,000 bookings ÷ (2,000 × 70% who buy) a minute | ≈ 14 minutes |
Only about 28,000 places, 2,000 a minute for 14 minutes, are ever admitted. The other 9.7 lakh fans in line will never shop, and that fact shapes how the line should behave.
A fair and honest line
If only about 28,000 of 10 lakh places ever reach the seats, two questions follow: who gets the early places, and how fast should the counter rise?
The rate is a dial, not a constant. It starts from load tests, then follows the booking service's p99 latency and error rate: admit faster while they stay low, slower when they rise, and stop when the seats are gone. If a hold's p99 climbs from 200 ms to 800 ms, halving the rate thins the crowd inside within about five minutes, the time one shopper stays. Status checks stay cheap because each one compares the fan's signed place with a single number that edge servers cache for a second.
Why not a plain rate limiter? A rate limiter drops extra requests without memory, so a fan's chance depends on how often they retry, which rewards bots and hammering. A waiting room remembers places, so retrying gains nothing.
Fairness. Fans who arrive before noon are shuffled into a random order when the sale opens; later arrivals join the back. Refreshing at 11:59:59 then gains nothing over arriving at 11:30, and a bot firing at exactly 12:00:00 cannot jump the line. Add one place per account, a CAPTCHA on joining, tokens tied to the account, and the rate limits from the rate limiter chapter.
Honesty. When every seat is held or booked, tell the fans still waiting, more than 9 lakh of them, straight away. Seats from failed payments and expired holds trickle back, so "Sold out for now; a few seats may return in the next 15 minutes" beats a spinner that never ends. The next part asks what happens when a piece of this machine fails mid-sale.
Part 4 · Failures and Trade-offs
What can fail
Every fix keeps the same two promises: no seat is sold twice, and no money is kept without a ticket. When a part fails mid-sale, slow the sale down rather than break either promise.
| Problem | Effect | Fix |
|---|---|---|
| Inventory leader dies mid-sale | Holds and payment callbacks fail for tens of seconds | Promote the synchronous follower; apps retry with the same idempotency key and the gateway resends callbacks; admission pauses meanwhile |
| Seat map cache dies | Map reads fall through to the database | Booking servers serve their last copy; only one rebuild a second reads the database |
| Payment gateway slow or down | Fans cannot pay before their deadlines | Stop admitting, leave paying seats alone, offer a second gateway |
| Queue store fails | Nobody can be admitted | Fail closed: pause admission rather than let everyone in |
| Bots hold seats and never pay | Real fans see a full map | One hold per account, short holds, a CAPTCHA at the queue |
| One show outgrows a partition | Lock waits pile up | Split inventory by section; each order stays in one section |
| A zone or region fails | A zone takes out the leader or some servers; a region takes out the whole site | The synchronous follower sits in another zone, so a zone loss is an ordinary failover; for a region, fail over to a standby, pause admission, and re-apply the gateway's successful payments before reopening, so no paid seat is sold again |
Notice the pattern: almost every fix pauses or slows admission, and none of them weakens the conditional update on the seat rows.
Trade-offs and running it
The design made five choices, and each has a price. Here they are side by side.
| Decision | Choice | Cost |
|---|---|---|
| Guarding seats | Conditional updates on seat rows | A hot show lives on one partition |
| Holds | A status and a 10-minute deadline on the row | Ghost holds keep some seats off sale |
| The rush | A waiting room admitting about 2,000 a minute | Fans wait; the sale lasts about 14 minutes |
| Seat map | A cached bitmap about a second old | Some holds fail with 409 |
| Payment | Callback plus a reconciliation job | A rare automatic refund instead of a rare double sale |
Each cost buys one of the two promises or keeps the rush away from them. A live sale then needs a few signals watched closely.
The rush sets the bill, not the bookings: edge capacity for tens of thousands of status checks a second, and booking servers scaled up for the 15 minutes a big sale lasts. The inventory database itself stays small.
Part 5 · Going further
Rows, counts and locks
Optional: three topics beyond the core design: other ways to guard seats, payments in detail, and berths sold by route segment.
The conditional update is one of four common ways to guard a seat. All are correct with care; they differ in where requests wait when thousands collide.
| Approach | How it works | Good at | Watch out for |
|---|---|---|---|
| Conditional update | UPDATE … WHERE status = 'available', then check the row count | One short statement, no separate read | Forgetting to check the count |
| Lock, then check | SELECT … FOR UPDATE, apply rules in code, then update | Rules too complex for a WHERE clause, such as never leaving one empty seat between groups | Others wait on the lock, so keep the transaction to milliseconds |
| Version check | Read the row's version, then UPDATE … WHERE version = 7 | Low contention: nobody waits | In a rush most attempts fail and must retry |
| In-memory set-if-absent | Set the key e_9:B-12 with a 10-minute expiry only if it is absent | Very fast, with expiry built in | A failover can lose holds, so the database must still guard the booking |
How it worksUPDATE … WHERE status = 'available', then check the row count
Good atOne short statement, no separate read
Watch out forForgetting to check the count
How it worksSELECT … FOR UPDATE, apply rules in code, then update
Good atRules too complex for a WHERE clause, such as never leaving one empty seat between groups
Watch out forOthers wait on the lock, so keep the transaction to milliseconds
How it worksRead the row's version, then UPDATE … WHERE version = 7
Good atLow contention: nobody waits
Watch out forIn a rush most attempts fail and must retry
How it worksSet the key e_9:B-12 with a 10-minute expiry only if it is absent
Good atVery fast, with expiry built in
Watch out forA failover can lose holds, so the database must still guard the booking
For numbered seats, the conditional update is the simplest correct choice; the others earn their place when rules get complex or contention is low.
Several seats at once. If seats are locked one at a time, lock them in a fixed order, such as by seat id. Otherwise Asha's request may lock B-12 and wait for B-13 while Ravi's locks B-13 and waits for B-12: a deadlock, which the database breaks by aborting one of them.
When seats are counts. A standing area with 5,000 places is one number: UPDATE tiers SET remaining = remaining - 2 WHERE tier_id = 'stand' AND remaining >= 2. Every booking now waits on the same row lock, so one row takes roughly a few hundred to a few thousand updates a second. Past that, split it as the sharded counters chapter does: 10 rows of 500, a random row per booking.
Best available. When fans ask for any 2 seats in section B, SELECT … FOR UPDATE SKIP LOCKED lets hundreds of requests each grab different free seats without queueing, as the task scheduler chapter claims tasks.
Whatever the fast path, the step from hold to booking is a conditional update on the seat rows, so a seat is booked only once even if a cache errs.
Paying without losing seats or money
Payment is the slowest and least reliable step, and it runs in another company's system. Two rules guide the design: never keep money without issuing a ticket, and never release a seat whose payment might still succeed.
- 1Checkoutbooking b_501 is created as pending; its seats move from held to paying
- 2The fan pays on the gateway's pagewhere the gateway allows it, the payment expires after 12 minutes
- 3The gateway calls backa signed message that may arrive late, twice or never
- 4One transaction confirmsseats to booked, booking to confirmed, plus an outbox row
- 5A worker sends the ticketQR code by SMS and email via the notification service (the notifications chapter)
Each message about Asha's booking b_501 has one safe response:
| What arrives | What the booking service does |
|---|---|
| A success callback | Check the signature and amount, then in one transaction: seats from paying to booked, booking from pending to confirmed, each only if still in the old state |
| The same success again | The conditions match nothing, so it replies 200 and changes nothing |
| A final failure callback | Only if still pending: booking to failed, seats back to available, the map refreshed |
| Nothing, 15 minutes after checkout | The 12-minute payment window has closed, so a reconciliation job asks the gateway for the final status, then confirms or releases |
| A success for seats already released | Refund automatically, tell the fan, and raise an alert: the rules above should prevent it |
Every row is a conditional update, so a message that arrives late or twice cannot change a seat it should not. Never confirm because the fan's browser came back to your site. The fan may close the tab mid-payment, and a crafted link can claim success. Gateways usually retry callbacks and offer an API to ask for a payment's status, so a reconciliation job can settle any payment whose callback is lost. The payments chapter goes deeper into gateways, ledgers and reconciliation.
hold_id is unique in bookings, so two taps on Pay create one booking and one payment order; the second tap gets the same payment page back.
Selling berths by route segment
A train is not a stadium. A berth sold from Delhi to Kanpur is empty again from Kanpur onwards, so one berth can be sold several times on one run. Each stretch from one stop to the next is a leg, and railway inventory is every berth times every leg.
Give every berth on a run a small integer used as a bitmap: bit k, worth 2^k, is 1 when leg k is taken. A train with 60 stops has 59 legs, which fit in one 64-bit number. A trip from stop i to stop j needs legs i to j − 1, written as a mask, and it can have a berth only if none of those bits is set.
legs D-K K-P P-Pt Pt-H berth B2-12 1 0 0 0 trip K to H 0 1 1 1 no overlap: sell; B2-12 is now full berth B2-13 0 0 1 0 trip D to Pt 1 1 1 0 overlap on P-Pt: try another berth
D Delhi, K Kanpur, P Prayagraj, Pt Patna, H Howrah. Each row is one berth's bitmap or one trip's mask, leg 0 on the left.
UPDATE berths SET taken = taken | 14 -- legs 1, 2 and 3 (2 + 4 + 8): Kanpur to Howrah WHERE run_id = 'r_77' AND berth_id = 'B2-12' AND (taken & 14) = 0; -- only if every one of those legs is free -- 1 row changed: held for this trip; 0 rows: try the next berth
PostgreSQL syntax: `|` is bitwise OR and `&` is bitwise AND on integers.
It is the same conditional update as for seats, and the row count still decides who wins. Pick the berth that fits most tightly, so a short trip fills a free Kanpur to Patna gap instead of cutting a fully free berth in two. Keep each hold's mask with the hold, because expiry must clear exactly its bits: taken = taken & ~mask. One number cannot say which bits belong to an expired hold, so here the expiry task and a sweeper matter more than lazy expiry.
Railways also split berths into quota pools, such as a general pool, a tatkal-style pool that opens the day before travel, and pools for particular groups or stations, each searched only by requests entitled to it. Each run's berths sit in one partition, as each show's seats did.
Part 6 · Interview kit
The 45-minute answer
A 45-minute round leaves about 40 minutes of design after introductions. Here is how this design fits that time.
| Minutes | What you say and draw | Key number or decision |
|---|---|---|
| 0–5 | Requirements: seat map, 10-minute holds, payment, ticket, a fair queue for big sales. Non-functional: never sell a seat twice, never keep money without a ticket, survive 10 lakh fans | 40,000 seats, 10 lakh fans at noon |
| 5–8 | Estimates that decide something: about 500,000 requests a second if everyone is let in, against about 22 bookings a second; one show is about 4 MB | the rush decides the design, not storage |
| 8–13 | API: seat map, queue, hold with an idempotency key, checkout, the gateway's callback. Data: a row per seat with a status, holds and bookings, all partitioned by event | one show lives on one partition |
| 13–22 | Draw: CDN and waiting room, booking service called with the access token, inventory database, seat-map cache, hold-expiry tasks, outbox to a ticket worker | admit about 2,000 fans a minute |
| 22–37 | Deep dive: two taps on one seat, solved by a conditional update; holds as a status with a deadline; waiting-room fairness | UPDATE … WHERE status = 'available', then check the row count |
| 37–40 | Wrap-up: leader failover, gateway down, region loss, what you would monitor; payment reconciliation, which the payments chapter covers | slow the sale down; never break a promise |
Pick the deep dive from the question. What stops two fans buying the same seat: the conditional update. What happens when 10 lakh fans arrive at noon: the waiting room. Failed or slow payments: holds, the paying state, then reconciliation.
- Say
- "Each seat is a row. A hold is one conditional update over every seat in the order, so exactly one request wins and the rest get 409."
- Say
- "Holds are a status with a 10-minute deadline, not database locks. Expired holds count as free, and a delayed task puts them back on the map."
- Say
- "A waiting room admits about 2,000 fans a minute, so the booking path sees a few thousand requests a second, not half a million."
- Avoid
- "Just add more servers." The bottleneck is one show's rows; more servers only fight over them harder.
- Avoid
- Holding a database lock while the fan pays. Payment takes minutes, and every other request on those rows would wait.
Interview drill
Interviewers often change one thing and watch how you adapt. Try each before opening the answer.
A stadium tour puts 10 shows of 40,000 seats on sale in the same minute, with 1 crore fans waiting. What changes?
- Each show is its own partition, so spread the 10 shows across database machines; one show's load is unchanged.
- Status checks become 1 crore ÷ 20 s = 500,000 a second, still one cached number per show at the edge.
- Admit per show, each with its own dial, so a slow show does not hold back the others.
Product adds a waitlist: after a sell-out, fans can pay for a waitlisted ticket that confirms itself if seats come back. What changes in the data model?
- Bookings gain a
waitlistedstatus and a waitlist number per show, issued in order. - Every released seat goes to the head of the waitlist in the same transaction that frees it.
- Payment is taken up front and refunded automatically if the ticket is not confirmed by a cut-off time.
The site must keep selling even if a whole region fails mid-sale. What do you add?
- A standby region with a copy of the inventory database; the waiting-room pages are already on the CDN.
- Replicate the confirm step synchronously across regions: at about 22 bookings a second, an extra 20 to 40 ms each is affordable.
- Holds may be lost in a failover, since fans can hold again; confirmed bookings must not be.
- Queue and access tokens are signed, so the standby can check them without the old region and fans keep their place.
- Common miss
- Trusting the fan's browser returning from the payment page as proof of payment; only the gateway's signed callback or status API counts.
- Common miss
- Spreading one show's seats across machines to share the load; the rush is one show, and a pair of seats would need a cross-machine transaction.
Wrap-up
The whole chapter on one page, for revision.
Next, the payments chapter, Design a Payment System, picks up where checkout stopped and follows the money itself.
Part 7 · Check yourself
Quick check
Q1A teammate locks the seats with SELECT … FOR UPDATE on Hold and keeps that transaction open until the payment callback. What goes wrong at noon?
Think it through, then check.
- Payment takes minutes, so each lock lasts minutes and every other request for those seats waits.
- About 10,000 shoppers inside means about 10,000 open transactions, far beyond a connection pool, so new requests time out.
- Fix: make a hold a status with a deadline, written in a transaction of milliseconds.
Q2Organisers add a weekend pass: Asha wants Saturday and Sunday seats in one order, both or neither. What changes?
Think it through, then check.
- The two shows live on different partitions, so one local transaction can no longer hold both.
- Hold each show's seats with its own conditional update; if the second fails, release the first at once.
- Check out both holds into one payment; the callback confirms each show's seats in its own transaction. A two-phase commit would hold locks on two machines at the busiest moment.
Q3Marketing wants the big sale to sell out in about 5 minutes, not 14. What would you change, and at what cost?
Think it through, then check.
- Admit about three times as fast, roughly 6,000 fans a minute, so about 30,000 shoppers are inside at once.
- The booking path then sees about 10,000 requests a second, not 3,300: add booking servers and load-test the partition.
- More fans chase the same front-row seats, so more holds fail with 409 and ghost holds grow.
Q4The inventory leader dies at 12:03, and its synchronous follower takes over 20 seconds later. What happens to Asha tapping Hold, and to a fan paying?
Think it through, then check.
- Asha sees an error; the app retries with the same idempotency key and finds or creates the hold. Nothing acknowledged is lost: the follower was synchronous.
- The payment continues at the gateway; a callback that fails in the gap is resent, and reconciliation catches any that never come.
- Admission pauses during the gap; holds due to expire in it can be extended by 20 seconds.
Q5Which building block puts an abandoned hold's seats back on sale, and why is it not enough on its own?
Think it through, then check.
- The task scheduler: a delayed task at each hold's deadline frees the seats and refreshes the map.
- Tasks can run late, or not at all while the scheduler is down.
- So correctness comes from lazy expiry: the hold query treats an expired hold as free; the task only keeps the map accurate.