Skip to lesson
‹

Courses / System Design Interview / Chapter 36

Module 3 · Design Real Products · Chapter 36 of 39

Design a Ticket Booking System

20 sections · ~41 min✓ Fact-checked

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
FunctionalBrowse a show and see a seat map with free and taken seats
FunctionalPick up to 4 seats and hold them for 10 minutes while checking out
FunctionalPay, then get a confirmed booking with a QR-code ticket
FunctionalIf payment fails or time runs out, the seats go back on sale
FunctionalFor a big sale, wait in a fair queue before choosing seats
Non-functionalNever sell a seat twice; never keep money without issuing a ticket
Non-functionalSurvive a rush of 10 lakh fans by making them wait, not by failing
Non-functionalA 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.

Out of scope today

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.

QuantityWorkingResult
Bookings on an ordinary day2 lakh tickets ÷ 2 per booking ÷ 86,400 s≈ 1.2 per second
Requests at noon if every fan is let in10 lakh fans × one request every 2 s≈ 500,000 /s
Bookings the big show can hold40,000 seats ÷ 2 per booking20,000
Bookings per second while it sells20,000 over about 15 minutes≈ 22 /s
Seat rows for the big show40,000 × about 100 B≈ 4 MB
Bookings stored per year1 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.

http
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 Conflict with 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.

eventsevent_id (pk)venue_idnamestarts_aton_sale_atmax_per_order
seatsevent_id (pk)seat_id (pk)sectionpricestatus: available, held, paying or bookedhold_idhold_expires_atbooking_id
holdshold_id (pk)event_iduser_idstatusexpires_atidempotency_key (unique)
bookingsbooking_id (pk)event_iduser_idhold_id (unique)amountstatus: pending, confirmed or failedgateway_ref (unique)created_at

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.

DataWhere it livesWhy there
Seats, holds, bookingsA relational database, partitioned by event, with a synchronous followerMulti-row transactions; a confirmed booking must survive a crash
Seat map for displayA cache: one bit per seat, free or taken, updated as seats change and rebuilt every secondRead by every shopper every few seconds; 40,000 seats ≈ 5 KB
Layouts, images, the waiting room pageThe CDNThe same for every fan and unchanged during a sale
Queue positions and the admission counterAn in-memory storeTens of thousands of tiny reads and increments a second
"My tickets" for each fanA copy keyed by user_id, filled from booking eventsKeeps 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.

Animated sceneFrom the waiting room to a ticket0:24

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

Core deep dive

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.
Animated sceneTwo taps on seat B-120:21

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.

sql
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 statusMeaningMoves to
availableAnyone may hold itheld
heldReserved by one hold for 10 minutespaying at checkout; available if the fan gives up or time runs out
payingThe fan is on the payment pagebooked when the gateway confirms; available only after the gateway reports a final failure
bookedPaid forStays 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.
Use one clock

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.

One fan's way through the waiting room
  1. 1Joina signed queue token with the fan's place; one per account
  2. 2Waitthe page checks every 20 seconds; the answer is one cached number
  3. 3Get inplace below admitted_up_to: a 20-minute access token tied to the account
  4. 4Shopevery hold and checkout call carries the token, and calls without one are turned away
  5. 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:

QuantityWorkingResult
Admission rateSet from load tests, adjusted live2,000 fans a minute
Shoppers inside at once2,000 a minute × about 5 minutes each≈ 10,000
Requests reaching booking10,000 shoppers × one every 3 s≈ 3,300 /s
Status checks from the line10 lakh fans × one every 20 s≈ 50,000 /s, each one cheap
Time to sell out20,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.

ProblemEffectFix
Inventory leader dies mid-saleHolds and payment callbacks fail for tens of secondsPromote the synchronous follower; apps retry with the same idempotency key and the gateway resends callbacks; admission pauses meanwhile
Seat map cache diesMap reads fall through to the databaseBooking servers serve their last copy; only one rebuild a second reads the database
Payment gateway slow or downFans cannot pay before their deadlinesStop admitting, leave paying seats alone, offer a second gateway
Queue store failsNobody can be admittedFail closed: pause admission rather than let everyone in
Bots hold seats and never payReal fans see a full mapOne hold per account, short holds, a CAPTCHA at the queue
One show outgrows a partitionLock waits pile upSplit inventory by section; each order stays in one section
A zone or region failsA zone takes out the leader or some servers; a region takes out the whole siteThe 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.

DecisionChoiceCost
Guarding seatsConditional updates on seat rowsA hot show lives on one partition
HoldsA status and a 10-minute deadline on the rowGhost holds keep some seats off sale
The rushA waiting room admitting about 2,000 a minuteFans wait; the sale lasts about 14 minutes
Seat mapA cached bitmap about a second oldSome holds fail with 409
PaymentCallback plus a reconciliation jobA 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.

ApproachHow it worksGood atWatch out for
Conditional updateUPDATE … WHERE status = 'available', then check the row countOne short statement, no separate readForgetting to check the count
Lock, then checkSELECT … FOR UPDATE, apply rules in code, then updateRules too complex for a WHERE clause, such as never leaving one empty seat between groupsOthers wait on the lock, so keep the transaction to milliseconds
Version checkRead the row's version, then UPDATE … WHERE version = 7Low contention: nobody waitsIn a rush most attempts fail and must retry
In-memory set-if-absentSet the key e_9:B-12 with a 10-minute expiry only if it is absentVery fast, with expiry built inA failover can lose holds, so the database must still guard the booking
Conditional update

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

Lock, then check

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

Version check

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

In-memory set-if-absent

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.

One final guard

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.

From checkout to ticket
  1. 1Checkoutbooking b_501 is created as pending; its seats move from held to paying
  2. 2The fan pays on the gateway's pagewhere the gateway allows it, the payment expires after 12 minutes
  3. 3The gateway calls backa signed message that may arrive late, twice or never
  4. 4One transaction confirmsseats to booked, booking to confirmed, plus an outbox row
  5. 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 arrivesWhat the booking service does
A success callbackCheck 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 againThe conditions match nothing, so it replies 200 and changes nothing
A final failure callbackOnly if still pending: booking to failed, seats back to available, the map refreshed
Nothing, 15 minutes after checkoutThe 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 releasedRefund 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.

A double tap on Pay

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.

text
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.

sql
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.

MinutesWhat you say and drawKey number or decision
0–5Requirements: 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 fans40,000 seats, 10 lakh fans at noon
5–8Estimates 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 MBthe rush decides the design, not storage
8–13API: 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 eventone show lives on one partition
13–22Draw: CDN and waiting room, booking service called with the access token, inventory database, seat-map cache, hold-expiry tasks, outbox to a ticket workeradmit about 2,000 fans a minute
22–37Deep dive: two taps on one seat, solved by a conditional update; holds as a status with a deadline; waiting-room fairnessUPDATE … WHERE status = 'available', then check the row count
37–40Wrap-up: leader failover, gateway down, region loss, what you would monitor; payment reconciliation, which the payments chapter coversslow 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 waitlisted status 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.

Q2Organisers add a weekend pass: Asha wants Saturday and Sunday seats in one order, both or neither. What changes?

Think it through, then check.

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.

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.

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.

Next chapter · 37 of 39Design a Payment System~42 min · 3 animated scenes