Library
37 min read

Design IRCTC tatkal booking

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.

ClientsWaiting roomFair queueAPI tierAvailability cacheBooking serviceInventoryHold sweeperPayment gatewayTicketing

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.

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.

Step 1: Understand the problem#

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 askThey sayWhat 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.

What you are building#

Functional requirements
What the system does.
  • 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.

Back of the envelope#

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

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.

Step 2: Propose the high level design#

The core entities
  • InventorySeats left for one train, date and class. One row, and deliberately the point of contention.
  • HoldSeats taken off the count for a few minutes while someone pays.heldpayment_unknownconfirmedreleased
  • Queue positionA signed token with a place in line and an arrival time. Editing it breaks the signature.
  • BookingA confirmed hold with a PNR, made only after the payment succeeds.
Never breaks: A seat is never sold twice, and a seat that was paid for is never released.

The API#

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

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 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.
POST/v1/holds
{
  "admissionToken": "eyJ...",
  "trainId": 12951,
  "date": "2026-09-02",
  "class": "3A",
  "seats": 2,
  "idempotencyKey": "h_91c4..."
}
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.

The data model#

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_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.
holdssame shard as inventory, keyed by train_id first
train_idintPKSame shard as the inventory row, so taking seats off the count and writing the hold is one local transaction.
hold_iduuidPK
idempotency_keyvarchar(64)UQFrom the app. A second tap returns this hold instead of taking two more seats.
user_idbigint
travel_date, class_codedate, char(3)Which inventory row the seats came out of, so an expiry knows where to put them back.
seatssmallint
stateenumheld, payment_unknown, confirmed, released. Only held and released move seats back and forth.
expires_attimestampIDXThe sweeper reads this index for held rows past their time. A payment_unknown row is skipped until the payment resolves.
Sample row
12951 | h_7a2c | h_91c4... | 4471 | 2026-09-02, 3A | 2 | held | 10:06:00
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.

The whole system on one whiteboard#

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.

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:

  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.
  2. 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.
  3. "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.
  4. "Hold a seat" goes to the booking service, which has exactly one owner per train.
  5. 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.
  6. Payment happens next, at a payment gateway run by someone else, which is slow.
  7. 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?

Step 3: Design deep dive#

The queue is the product#

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.

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.

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.

Not selling seat 14B twice#

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.

The three strategies, drawn side by side:

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

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.

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.

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.

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#

Move the clock and watch which box turns red.

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.
Read every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

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.

The level bar#

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
  1. The defining number
  2. The waiting room
  3. The last seat
  4. Stale or not
  5. Unpaid holds
Pick 0, 1 or 2 on every row. Be the strict interviewer, not the kind friend.

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.

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.

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?

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.