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.
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
- 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.
- 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.
- 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
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.
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
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”.
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.
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.
| 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. |
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
The API
{ "trainId": 12951, "date": "2026-09-02", "class": "3A" }{
"admissionToken": "eyJ...",
"trainId": 12951,
"date": "2026-09-02",
"class": "3A",
"seats": 2,
"idempotencyKey": "h_91c4..."
}{ "paymentRef": "UPI2609021000..." }Trade-offs
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Admission control at the edge | The 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 update | Correct 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 pay | Contention 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 search | Searches 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
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?
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.
