At 10am the tatkal window opens. (Tatkal is the quota of train seats kept for people who
need to travel at short notice.) For the next ten minutes a system that spends most of its
day fairly idle takes a couple of hundred times its normal traffic. Almost all of it wants
the same few hundred seats, on the same few trains.
Watch one booking go all the way through.
Arrive. At 10am everyone lands on the waiting room, not on the booking service. It does almost no work per request, so it can take the whole burst.
fairly idle all day10:00a couple of hundred timesfor the next ten minutesall for the same few hundred seats
This is not the URL shortener's problem wearing a hat. There, every request could be
served, and the work was spreading load. Here most requests must fail, because there are
far more people than seats. The design question is how to fail them quickly, fairly and
without taking the system down on the way.
A few words come up all through this page:
A hold is a seat taken out of inventory for a few minutes while you pay. Nobody else
can buy it, and if you never pay, it goes back.
The waiting room is a page that gives you a place in line before you are allowed to
book, like the token machine at a bank counter.
Atomic means "all at once or not at all". An atomic update checks and changes a value
in one step, so nothing can sneak in between the check and the change.
Inventory is the count of seats still for sale, one number per train, date and class.
The interesting requirements here are about fairness and about failure, not about raw
speed. Ask what a rejected user is owed, because that answer shapes the front of the
system.
You ask
They say
What it settles
How much traffic, over what window?
A couple of hundred times normal, for about ten minutes, at a time everybody knows in advance.
You can prescale, which means adding servers before the rush instead of during it. The spike is scheduled, so capacity is a calendar problem, and the design only has to survive a known ten minutes rather than an unknown one.
How many of these requests can succeed?
Almost none. A few hundred seats on a popular train, a few hundred thousand people.
The whole shape. This is a rejection system, so the goal is to fail people cheaply and early rather than to serve everybody.
Can the same seat ever be sold twice?
Never.
A single place per train where seats are claimed one after another. "Copies catch up later" is not an answer for seat inventory, and pretending otherwise is the fastest way to fail this question.
Does it have to be fair, or just fast?
Fair. First come first served, visibly.
A queue with a position you can show people, rather than a retry storm where the fastest network wins. Fairness is a product requirement, not a nicety.
What happens between choosing a seat and paying for it?
The seat is held for a few minutes.
A hold with a hard expiry, and a sweeper that releases abandoned holds. Every unpaid hold is a seat nobody else can buy.
Is stale availability acceptable?
Yes, as long as booking is exact.
The "seats left" number can be cached and a little out of date, which removes the vast majority of reads from the database. Only the commit has to be true.
Search trains and see availability. Read heavy, cacheable, and the first thing to fall over.
Hold specific seats for a few minutes while the user pays. The hold is the hard part, not the booking.
Confirm on payment, release on timeout. Money and inventory live in two systems, which is the payment rail problem again.
Never sell the same seat twice. Non negotiable. Everything else can degrade.
Non-functional requirements
How well it has to do it. The numbers you commit to.
A ten minute window carrying about 200 times the normal rate.
Correctness over availability on inventory. A wrong booking is worse than a failed one.
Tell a user they failed within a couple of seconds. A spinner that lasts 90 seconds is worse than a fast rejection.
Fairness: arriving first should count for something.
Out of scope
What you cut, and say so out loud.
Cancellations, refunds and the waiting list promotion rules. Genuinely complex, and a separate design question.
Fare and quota calculation. Deep domain logic that changes nothing structural here.
Fraud and bot detection. Enormous in practice on this exact system, and worth naming as a real constraint.
Seat allocation preferences. Assume seats are interchangeable within a class.
"Correctness over availability" is the same choice the CAP theorem
describes: when a network fault splits the system, each part can either keep answering or
stay correct, not both. Inventory stays correct, and refuses to answer if it has to.
Before the sliders, a small version you can do in your head. One train has 400 seats and
200,000 people want them. That is 500 people per seat. If every one of them gets an answer,
499 out of every 500 answers are "no". (The back of the envelope
chapter covers the method.)
one seat400 seats,200,000 people500 people per seat499 of every 500 answers are no
The napkin below counts the whole rush across all trains together, so its default is more
seats and more people than one train.
Demand against supply
400 thousand
5000
6
People competing400,000
People per seat400,000 / 5,000 = 80 to 1
Requests, with retries400,000 x 6 = 2,400,000
Peak rate over two minutes20,000 per second
Requests that must fail98.75%
80 to 1
people per seat, and this ratio is the design
Almost every request is going to fail. That reframes the whole problem: the system is not a booking system during this window, it is a rejection system that occasionally books. Rejecting cheaply, early, and before anything touches the inventory database is where the design effort goes.
The retry multiplier is not a constant
Drag the attempts slider. Retries are the one input the system controls. A fast, clear
rejection produces one or two retries. A request that hangs for thirty seconds and then
times out produces six, because the user has no idea what happened and taps again. Slow
failure manufactures its own load, which is why "fail fast" is a capacity decision here
and not a nicety. Retries multiply load
shows the same effect inside a backend.
fast, clear nono seatsone or two retrieshang 30 s, then time outsix taps"fail fast" is a capacity decision
Three calls: get in line, hold seats, confirm after paying. Each reply uses an HTTP status
code to say what happened: 201 means "created", 409 means "conflict, somebody got
there first", and 202 means "accepted, but not finished yet".
Why: A signed token carrying position and arrival time. Signed means the server stamps it with a secret so any edit is detected. A client that can edit its own position has no queue, and this is a system with strong incentives to cheat.
returns 201 { "holdId": "...", "expiresAt": "2026-09-01T10:06:00+05:30" } or 409
Why: A 409 when seats are gone, immediately, with no retry hint. The idempotency key matters more than usual: a user who taps twice on a bad connection must not consume two seats out of an inventory this scarce.
POST/v1/holds/{holdId}/confirm
{ "paymentRef": "UPI2609021000..." }
returns 200 { "pnr": "..." } or 202 if payment is still unresolved
Why: 202 is the honest answer while payment is ambiguous. Forcing a yes or no here would mean either releasing a seat that was paid for or confirming one that was not.
An idempotency key is a random id the app makes once, when the user taps "Book", and
sends with every retry of that tap. The server remembers which keys it has already handled,
so the second tap gets the first tap's answer instead of a second hold.
How an idempotency key works walks
through it with a live demo.
Two tables. inventory is how many seats are left, one row per train, date and class.
holds is who is sitting on some of them right now. The shard key
of both is train_id, so every request for one train lands on one database machine on purpose.
inventoryone row per train, date and class. Sharded by train_id.
train_id
int
PK
Shard key. All contention for one train lands on one shard by design.
travel_date
date
PK
class_code
char(3)
PK
Contention is per class, so 3A and SL do not block each other.
available
int
Decremented by the conditional update. Never read then written.
held
int
Seats reserved but not yet paid for. Returns to available when a hold expires.
version
bigint
For optimistic concurrency where a conditional update is not expressive enough.
Sample row
12951 | 2026-09-02 | 3A | 4 | 27 | 88214
One row is the contention point, deliberately. Spreading a train's seats across many rows would reduce contention and make it much harder to answer 'are there any seats left' without a scan. Concentrate the contention and make the operation on it atomic and short.
holdssame shard as inventory, keyed by train_id first
train_id
int
PK
Same shard as the inventory row, so taking seats off the count and writing the hold is one local transaction.
hold_id
uuid
PK
idempotency_key
varchar(64)
UQ
From the app. A second tap returns this hold instead of taking two more seats.
user_id
bigint
travel_date, class_code
date, char(3)
Which inventory row the seats came out of, so an expiry knows where to put them back.
seats
smallint
state
enum
held, payment_unknown, confirmed, released. Only held and released move seats back and forth.
expires_at
timestamp
IDX
The sweeper reads this index for held rows past their time. A payment_unknown row is skipped until the payment resolves.
The hold row is the promise. Every one of them ends as confirmed or released, and the sweeper is what guarantees that nobody's unpaid hold keeps a seat for ever.
Figure 1 is the picture at the top of the page, drawn flat, with the CDN added at the edge.
A CDN is a network of servers close to users that serves static files and can drop
obvious bot traffic before it reaches you.
1obvious bots goneyour positionadmitted only2how many left?3hold a seat4commit the hold5expire holdstake payment6on success7Clients400k at 10:00:00CDN + edgestatic, bot filterWaiting roomadmission tokenFair queueposition per userAPI tierstateless, rate limitedAvailability cacheapproximate on purposeBooking serviceone owner per trainInventorysharded by trainHold sweeperreleases abandonedPayment gatewaysomeone else, slowTicketingPNR, SMS, email
Figure 1. Read this as a funnel, not a pipeline. Each filter rejects a large share of what reaches it, more cheaply than the next one could, so a request only costs a database write after four cheaper things failed to stop it.
Walking Figure 1:
Clients arrive at the waiting room. The CDN in front has already dropped obvious bots.
The fair queue hands each person a signed position.
Admitted users only reach the API tier. "Stateless" means any API server can answer
any request, because none of them keeps anything in memory between requests, so you can
add more of them freely. They are also rate limited, which the
rate limiter question covers end to end.
"How many left?" is answered by the availability cache, on purpose approximate. A
cache is a fast copy of data kept in memory so that most
reads never reach the database.
"Hold a seat" goes to the booking service, which has exactly one owner per train.
The hold is committed in the inventory database with one atomic update. The inventory
is sharded by train: split across many database machines, with every row for one train
on the same machine.
Payment happens next, at a payment gateway run by someone else, which is slow.
On success, ticketing issues the PNR (the booking reference printed on your ticket)
and sends the SMS and email. Meanwhile the hold sweeper keeps returning expired holds.
Read the numbered path as a funnel. The waiting room admits a fraction of arrivals. The
availability cache answers most of the admitted requests without touching a database. Only
genuine hold attempts reach the booking service, and only successful holds reach the
inventory database. By the time a request costs a write, the system has already rejected
most of its neighbours for almost nothing.
The two boxes worth defending are the waiting room and the booking service. The waiting
room turns a stampede into a line, which is a product decision as much as a technical one.
The booking service is the deliberate bottleneck: one owner per train, so that seat 14B
has exactly one place in the world where it can be claimed.
Your answer
200,000 people want 400 seats on one train in the same few seconds. Which box in Figure 1 fails first if you delete the waiting room, and is it the database?
Picture a railway ticket counter where 500 people rush the window at once. The clerk is no
faster for the crowd, the shouting slows everything down, and whoever has the longest arm
wins. A token machine at the door fixes all three: one person at a time, everyone knows
their number, and when the tickets run out the person at the door can say so to everyone
still in line.
rush the windowthe longest arm winsa token at the door12345one at a time, everyoneknows their number
200,000 people want one train at 10:00:00. What stands between them and its inventory?
1
First try
Nothing. Scale the booking path to take every request
Add servers until the booking service and the database can take the full burst.
Where it breaks: There are 400 seats on the train. Every request still lands on the same inventory row, so they queue behind each other inside the database, connection pools fill and requests time out. Users see a spinner, tap again, and double the load. You paid for a lot of capacity and still disappoint almost everyone.
2
Better
Rate limit at the API, and answer the rest with 429
Let a fixed number of requests per second through and reject everything above it with "too many requests". The database is protected.
Where it breaks: It is not fair. Who gets through depends on whose request landed in the one lucky millisecond, so the fastest network and the most refreshing win. A rejected user has no idea where they stand, so they retry, and the retries become most of the traffic.
3
What you ship
A waiting room that hands out positions and admits at the serving rate
Everyone who arrives gets a signed token with a queue position. The room lets people into the booking path only as fast as it can serve them. When seats run out, it stops admitting and tells everyone still waiting, straight away, without any of them reaching the database.
This is not a workaround. It is better for every party. The backend runs at its designed
rate rather than in overload, so it stays correct and fast for the people it is serving.
Users see a position that moves instead of a spinner that lies. And a queue position
assigned at arrival is a far more defensible definition of fairness than "whoever's
packets arrived during the one second when the server was not saturated". Turning people
away early and cheaply has a name, load shedding, explained in
rate limiting vs throttling vs backpressure.
Here is one user's trip, step by step.
UserWaiting roomBooking servicePaymentsarrive at 10:00:00.4position 84,203admitted, token valid 5 minhold 2 seats, train 12951decrement inventory, create holdheld until 10:06paypaid, confirm hold
1/8 The user hits the waiting room, not the booking service. This endpoint does almost nothing, so it can absorb the whole burst on cheap stateless capacity.
With 500 people per seat all aiming at one row, the way you take a seat off the count is
the whole system. Contention is the word for many requests fighting over the same piece
of data at the same moment.
Two requests both want the last seat. How does the booking service take it off the count?
1
First try
Read the count, check it, write the new count
Read available = 1. It is above zero, so write available = 0 and tell the user they have the seat.
Where it breaks: Two servers can both read 1 before either writes. Both decide there is a seat, both write 0, and two people are told they hold one berth. Neither request saw an error. This is called a lost update, and it appears the moment you run more than one application server.
2
Better
Lock the row first, then read and write
Take a row lock (SELECT ... FOR UPDATE in SQL), so everyone else waits until you finish. Correct: nobody can read in between.
Where it breaks: At 500 to 1, almost every request spends its life waiting for the lock, holding a database connection while it waits. If the lock is held across anything slow, like a network call to another service, every other booking for that train waits behind it.
3
What you ship
One conditional update, and count the rows it changed
A single statement: take one off available, but only where available is above zero. The database does the check and the change in one step and reports how many rows it changed. 1 row means the seat is yours. 0 rows means somebody beat you, and you get a clean 409. No lock is held across a network call.
Try the race yourself. In "Read, then write", run A, then B, then A, then B, and watch both
requests win the same seat. Switch to the conditional update and try any order you like.
Two requests, one seat left
Inventory row, train 12951available = 1
Request Awaiting
next: SELECT available FROM inventory WHERE train_id = 12951
Request Bwaiting
next: SELECT available FROM inventory WHERE train_id = 12951
Try this order: A, B, A, B. Both requests read before either writes.
The SQL is shortened. The real WHERE clause also names the travel date and class, because the row is one train, one date, one class.
The first rung is the classic lost update,
and the second is a pessimistic lock.
The same article shows the version number trick that the version column of the inventory table is for.
Read the count, check it is positive, write the decrement. Both requests read 1, both decide there is a seat, both write 0. Two people are holding one berth and neither request saw an error. This is the bug the whole design exists to prevent, and it appears the moment there is more than one application server.
The honest answer combines the last two. An atomic conditional update is correct and
cheap, and it should be the mechanism. Serialising per train on top of it, one ordered
queue per train with a single consumer, is what makes the order explainable to somebody who
asks why they lost. A queue keeps its order only within one partition, which is why it is
one per train: see partitions decide your parallelism.
On a public system carrying this much frustration, an explainable order is a genuine
requirement.
one queue per trainone consumer14Bone place to claim itan order you can explain to whoever lost
That is why the inventory table is one row per train, date and class,
sharded by train_id: every request for one train lands on one database machine on purpose,
and the operation on that row is one short atomic statement.
Why may search show an old number while booking may not? Because they are different
promises. Search says "roughly this many seats", and a few seconds of staleness costs
nothing. Booking says "this seat is yours", and staleness there sells a seat twice.
Deciding consistency per feature like this is the idea in
strong vs eventual consistency.
A hold takes a seat out of inventory before any money exists. That means every hold is a
promise the system has to clean up after.
When does the seat leave inventory, and what happens if the money never arrives?
1
First try
Take payment first, then look for a seat
The user pays, and only then does the system try to book.
Where it breaks: Payment is slow and external. While thousands of people are paying, the seats go to whoever finishes first, and everyone else has paid for a seat that no longer exists. Now you owe thousands of refunds.
2
Better
Hold the seat first, release it when a timer runs out
The atomic update creates a hold with an expiry a few minutes away. If nobody pays by then, the seat goes back.
Where it breaks: Payment can go silent for thirty seconds, right as the timer runs out. If you release the seat while the money may have moved, you can sell a seat that was paid for to someone else.
3
What you ship
Hold with an expiry, a sweeper, and no release while payment is unknown
A sweeper job returns expired holds to inventory all the time. A hold whose payment is in an unknown state is never released: the system asks the gateway about it by transaction id, then either confirms the booking or releases the seat and refunds.
The hold row carries an expiry a few minutes out. A sweeper runs constantly, finds expired
holds, and returns those seats to available. This job is not a background nicety: during
the tatkal window it is actively returning inventory to a queue of people still waiting,
so its lag is directly visible as seats that exist but nobody can buy.
held for a few minutesexpired, unpaidsweeperstill in linea lagging sweeper: seats that exist, that nobody can buy
Then the ambiguity you have already met on the payment rail:
payment says nothing for thirty seconds. The seat is held, the money may or may not have
moved, and the user is watching. The rules are identical. Never retry the payment, ask
about it by transaction id. Do not release the hold while its payment is in an unknown
state, because releasing a seat that was in fact paid for is far worse than holding it a
few minutes longer. Resolve, then either confirm or refund.
Healthy. Ordinary traffic. Searches are cached, bookings trickle in, and the inventory row for train 12951 is touched a few times a minute.
Read every step as text
09:59 · Healthy. Ordinary traffic. Searches are cached, bookings trickle in, and the inventory row for train 12951 is touched a few times a minute.
10:00:00 · Degraded. 400,000 people arrive within about two seconds. The waiting room takes the burst, which is exactly what it is for: it does almost no work per request and it holds nothing that has to be correct.
no waiting room · Down. Remove admission control and the same arrivals go straight to the booking path. Every request contends on one inventory row, transactions queue, connection pools fill, and requests time out. Users see a spinner, retry, and double the load. Nobody has been told they failed, so nobody stops trying.
admitted at rate · Degraded. With the room admitting at the rate the booking service can serve, the inventory row sees a steady stream of short atomic updates instead of a stampede. It is busy and it is fine. Everyone else has a position number rather than a hanging request.
seats gone · Healthy. Seats run out about ninety seconds in. The waiting room stops admitting and tells the remaining 380,000 people immediately, from cheap stateless capacity, without a single one of them reaching the database. Fast honest rejection is the cheapest response in the entire system.
One moment from this round, answered three ways. Pick the one you think is staff level before
the labels appear. (What changes at senior and staff
explains the gap.)
Pick the staff level answer
InterviewerSearch showed 4 seats left, and the user got a 409 when they tried to hold one. Is that a bug?
Show which is which without guessing
Answer A: Senior. Knows the split and why it exists. Leaves the user staring at a number that misled them.
Answer B: Staff. Defends the trade-off, then fixes the experience around it, which also cuts retry load.
Answer C: Mid level. Puts every search on the one row that is already the hottest in the system, to fix a number that was allowed to be stale.
Then run the question cold, with a 45 minute timer and no notes, and score the run on the
five things this question is really testing.
Score your last run
0 / 10
The defining number
The waiting room
The last seat
Stale or not
Unpaid holds
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.
200,000 people, 400 seats, ten seconds. What breaks first?
They want to know whether you reach for scale or for admission control.
You
The inventory row, if I let them all reach it. Every request contends on the same row for that train and class, transactions queue behind each other, connection pools fill and requests start timing out. But I would not try to scale through that, because there are only 400 seats. Almost every request must fail, so the design goal is to reject cheaply and early rather than to serve everyone correctly.
Reframes the problem in the second half. That reframing is the answer to this question.
Interviewer
So how do you reject cheaply?
Following the reframe, which is a good sign.
You
A waiting room in front. Everyone who arrives gets a signed token with a queue position, and we admit to the booking path at the rate it can actually serve. Once seats run out the room stops admitting and tells everyone else immediately from cheap stateless capacity, with nothing reaching the database. It also removes the retry storm, because a user with a visible position does not hammer refresh the way a user with a spinner does.
Ends on the second order effect. Retries being a function of how you fail is the insight most candidates miss.
Interviewer
Two requests both see one seat left. Walk me through it.
The correctness question. There is exactly one right answer.
You
They only both see it if I read and then write, which is a lost update and will absolutely happen with more than one app server. Instead it is a single conditional statement that decrements where available is greater than zero, and I check how many rows it changed. One row means the seat is yours, zero means somebody beat you and you get a clean 409. No lock is held across a network call and there is nothing to race.
Names the bug, names why it happens, then gives the one line fix and what it returns.
Interviewer
A user holds a seat, starts paying, and the payment gateway stops responding. What do you do with the seat?
Deliberately uncomfortable, because both obvious answers are wrong.
You
Nothing, until I know. I hold it past the normal expiry while payment is in an unknown state, and I status check the gateway by transaction id rather than retrying. Releasing a seat whose payment did in fact succeed means selling it twice and refunding someone who was standing on a platform, which is much worse than one berth being unavailable for a few extra minutes. Once the gateway answers, I either confirm or release and refund.
Picks the asymmetry explicitly and justifies it in terms of the user, not the system.
Interviewer
How would you know the waiting room itself is working?
Open ended. A chance to be specific and to admit a limit.
You
Admission rate against booking success rate, since if I am admitting people who then fail, the room is releasing too fast. Time from arrival to a decision at p99, because the promise is a fast answer rather than a good one. And the retry rate per user, which is my signal that failures are not landing clearly. I have not run this pattern at hundreds of thousands of arrivals in a few seconds, so I would want a load test with realistic client retry behaviour before trusting any of my numbers for the admission rate.
Metrics derived from the design decisions, and an honest boundary on the end. Both score.
p99 in that last answer means the time 99 out of 100 users beat, so it tracks the unlucky
one in a hundred rather than the average. Percentiles, quickly
explains why averages hide exactly the users who are suffering.
1. Why is a waiting room a better answer than scaling the booking path to handle the full burst?
2. Two servers both read available = 1 and both decrement. What is the fix?
3. Payment is unresolved and the hold is about to expire. What is the safest action?
Worth memorising
A few hundred seats against a few hundred thousand people. Almost every request must fail, and fail cheaply.
Two hundred times normal traffic for ten minutes, at a time printed in advance. You can prescale for this.
Availability may be stale. The commit may not. That split removes most reads from the database.
One owner per train. Seat 14B has exactly one place in the world where it can be claimed.
Say this in 60 seconds
The defining number is people per seat, not requests per second: 400,000 users for a few thousand seats means almost every request must fail, so the system's job during that window is fast rejection rather than throughput. I would put a waiting room in front that hands out signed queue positions and admits people at the rate the booking path can actually serve, and once seats are gone it rejects everyone else immediately without touching a database. That also cuts load, because retries are a function of how slowly you fail. Inventory is one row per train, date and class, updated with a single conditional decrement where available is greater than zero, and the affected row count decides the outcome, since read-then-write is a double booking as soon as there are two app servers. Seats are held before payment so the contention resolves in milliseconds, with a sweeper returning expired holds, and if a payment goes unresolved I hold the seat and status check rather than releasing it, because selling a paid seat twice is far worse than one berth being unavailable for a few minutes.