LearnHLDDesign IRCTC tatkal booking

Design IRCTC tatkal booking

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, and almost all of that traffic wants the same few hundred seats on the same few trains.

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.

Your answer

200,000 people are trying to book 400 seats on one train, in the same few seconds. What is the first thing that breaks, and is it the database?

What you are building

In scope
  • 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 in two systems, which is the payment rail problem again.
  • Never sell the same seat twice. Non negotiable. Everything else can degrade.
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.
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.

The numbers

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

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.

The design

Figure 1. Four filters before anything reaches inventory. Each one rejects a large fraction of traffic more cheaply than the next one would.

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.

Deep dive one: the queue is the product

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

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.

Deep dive two: not selling seat 14B twice

Under 500 to 1 contention on one row, the way you decrement inventory is the whole system.

Contention strategy
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_idintPKShard key. All contention for one train lands on one shard by design.
travel_datedatePK
class_codechar(3)PKContention is per class, so 3A and SL do not block each other.
availableintDecremented by the conditional update. Never read then written.
heldintSeats reserved but not yet paid for. Returns to available when a hold expires.
versionbigintFor 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.

Deep dive three: the hold that nobody paid for

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.

Break it

10am
09:59
09:5910:00:00no waiting roomadmitted at rateseats gone
Healthy. Ordinary traffic. Searches are cached, bookings trickle in, and the inventory row for train 12951 is touched a few times a minute.

The API

POST/v1/queue/join
{ "trainId": 12951, "date": "2026-09-02", "class": "3A" }
returns 200 { "position": 84203, "token": "eyJ...", "etaSeconds": 210 }
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.
POST/v1/holds
{
  "admissionToken": "eyJ...",
  "trainId": 12951,
  "date": "2026-09-02",
  "class": "3A",
  "seats": 2,
  "idempotencyKey": "h_91c4..."
}
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.

Trade-offs

ChoiceWhat you gainWhat you payPick it when
Admission control at the edgeThe backend runs at its designed rate, so it stays correct and fast, and users get a moving position rather than a spinner.A queue service to build and operate, and users waiting in a room instead of trying.Demand exceeds supply by orders of magnitude in a short window. Flash sales, ticket drops, exam results.
Atomic conditional updateCorrect under any concurrency, no lock held across a network call, and the row count tells you the outcome.One row per train and class becomes a serialisation point.Always, for inventory. Read-then-write is a double booking waiting for a second server.
Hold then payContention is resolved in milliseconds, and the slow external payment happens with the seat already secured.Inventory is locked up by people who may never pay, so a sweeper is now load bearing.Whenever payment is slower than the contention window, which is essentially always.
Approximate availability on searchSearches serve from cache and never touch the contended row, removing most read load.Users see counts a few seconds stale and can attempt a booking that is already gone.Always during a rush. Exact availability on a read is a promise you cannot keep anyway at this contention.

Interview replay

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

Checkpoint

Checkpoint

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?

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.

IndGeek provides solutions in the software field, and is a hub for ultimate Tech Knowledge.