At 10am the tatkal window opens. 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.
IRCTC tatkal booking / at a glance
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, and the design question is how to fail them quickly, fairly
and without taking the system down on the way.
The interesting requirements here are about fairness and about failure, not about
throughput. 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. 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, 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 serialisation point per train. There is no eventual consistency answer to 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 availability number can be cached and approximate, which removes the vast majority of reads from the database. Only the commit has to be true.
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
Notice what happens when you drag the attempts slider. Retries are the one input the
system controls: a fast, clear rejection produces one or two retries, and a request that
hangs for thirty seconds and then times out produces six. Slow failure manufactures its
own load, which is why "fail fast" is a capacity decision here and not a nicety.
Why: A signed token carrying position and arrival time. Signed because 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-08-25T10:06:00Z" } 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 binary result here would mean either releasing a seat that was paid for or confirming one that was not.
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.
Read the numbered path as a funnel rather than a pipeline. 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?
The instinct is to scale the booking path until it can take 200,000 concurrent requests.
That is the wrong instinct, because there are 400 seats. Serving all that traffic
correctly still ends with almost everyone failing, and you paid for the capacity to
disappoint them faster.
Admission control instead. When the window opens, arrivals get a place in a queue and a
position they can see. The system admits people to the booking path at the rate it can
actually serve, and everyone else waits on a page that is honest about what is happening.
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".
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.
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 is what makes
the ordering explainable to somebody who asks why they lost, which on a public system
carrying this much frustration is a genuine requirement.
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.
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.
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.
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.
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.
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.