- Tatkal is a contention and fairness problem, not just a scaling problem: a few hundred berths per popular train, demand many times larger, and nearly all of it in the first minute.
- Put a virtual waiting room at the edge (CDN-served, signed queue tickets, randomised order for everyone who arrived before opening) and admit users at the rate the booking engine can sustain.
- Model berths over route legs (bitmasks), keep inventory per (train, date, class, quota) in a single-writer partition, and allocate in memory with a durable log.
- Hold berths for a short payment window; treat payment as an asynchronous state machine with automatic refunds and reconciliation.
- Layer bot defenses (identity verification, rate limits, behavioural signals, per-account caps, agent restrictions); bots are the main fairness threat.
Every Indian engineer knows the Tatkal ritual: log in at 9:55, keep the passenger details ready, refresh at 9:59:58, click "Book Now" at 10:00:00, watch the spinner, and see "Waiting List" or "Not Available" by 10:02. "Design IRCTC Tatkal" is a brilliant interview question precisely because everyone has felt the pain, and because the honest answer contains an uncomfortable truth: no architecture can give 2 lakh people the 400 berths that exist. What good design can do is keep the system up, allocate correctly, and make the outcome fair rather than a contest of bots and refresh speed.
Note on facts: IRCTC's front end and CRIS's Passenger Reservation System (PRS) are real systems whose internals are mostly not public. This post designs a Tatkal system from first principles and is explicit when it refers to publicly announced rules. Numbers are interview assumptions.
Anatomy of a Tatkal Morning
Tatkal opens one day before the journey date: 10:00 AM for AC classes and 11:00 AM for non-AC classes (as per IRCTC's published rules; verify the current rules). Here is what the load looks like around opening, which shapes everything else:
| Time | User behaviour | System load |
|---|---|---|
| 09:45–09:58 | Users log in, open the booking form, pre-fill passengers | Login and session creation surge; static assets |
| 09:58–09:59:59 | Refreshing availability, waiting for the clock | Read storm on availability; many idle open sessions |
| 10:00:00–10:00:30 | Everyone submits at once | Peak write burst on a few hundred popular train-classes |
| 10:00:30–10:05 | Payment pages, retries, "money deducted?" panic | Payment gateway load, status checks, retries |
| 10:05–10:30 | Waitlist bookings, cancellations, alternate trains | Tapering load |
The demand is also hugely skewed: a few hundred trains (Delhi–Patna, Mumbai–Gorakhpur, Bengaluru–Guwahati routes around festivals) attract most of the contention, while most trains are fine.
Requirements
Functional
- Users search trains between stations for a date; see availability per class and quota (General, Tatkal, Premium Tatkal, Ladies, Senior Citizen, etc.).
- Book up to the allowed number of passengers per ticket with berth preferences.
- Pay via UPI, cards, net banking or wallet; get a PNR with confirmed (CNF), RAC or waitlist (WL) status.
- Tatkal opens at fixed times; Tatkal-specific rules (fare, cancellation, ID requirements) apply.
- Cancellations release berths; waitlisted tickets get upgraded through charting rules.
Non-functional
- Correctness above all: never sell a berth-segment twice; never confirm a ticket without payment; never keep money without a ticket.
- Survive the spike: the site must stay up at 10:00; degraded but orderly beats crashed.
- Fairness: genuine users should have a fair chance relative to bots, agents and people with faster connections.
- Latency: once admitted, booking should complete in seconds, not minutes.
- Auditability for a public-sector system: who got what, when, and why.
Back-of-the-Envelope Estimates
Interview assumptions (not IRCTC's real figures):
- 2M users attempt Tatkal bookings in the 10:00 window across all trains.
- Each user generates ~30 requests in the window (login, search, refreshes, form submit, payment redirects, status polls) → 60M requests. If half arrive in the first 2 minutes: 30M / 120 s ≈ 250k requests/s, with second-level peaks well above that.
- Tatkal inventory: assume ~150,000 Tatkal berths across all trains for a day, of which the 500 hottest train-classes have ~200 each = 100,000 berths with perhaps 10x oversubscription.
- Bookings: even if every berth sells in 5 minutes, that is 150,000 / 300 s = 500 bookings/s. The write throughput needed is modest. The problem is that 250k requests/s are fighting over 500 writes/s, concentrated on a few hundred inventory partitions.
- Per hot partition: a popular AC 3-tier Tatkal quota of ~100 berths receives tens of thousands of attempts in a minute. Any design that takes a database row lock per attempt on that partition collapses.
Why Tatkal Feels Broken (an Honest Analysis)
Interviewers often ask "why does IRCTC struggle?" A thoughtful answer avoids mocking the engineers and identifies structural causes:
- Scarcity is the root cause. When demand is 10x supply, 90% of users fail no matter what. Users experience failure as "the site is broken" even when it worked perfectly.
- Synchronised demand. A fixed opening time turns a day's demand into a single-minute spike. Systems sized for average load (and even 10x peaks) are overwhelmed by 100x synchronous bursts.
- Contention on tiny inventory. Read-heavy spikes are easy to cache. Writes to the same 100-berth pool cannot be spread across machines; they serialise somewhere.
- Legacy coupling. Reservation inventory has historically lived in PRS, which also serves station counters across the country. The web front end can scale horizontally; the system of record cannot be replaced overnight.
- Multi-hop payments. Each booking redirects through banks, UPI apps and gateways with their own latency and failure rates. A slow bank at 10:01 holds berths in limbo and generates "money deducted, ticket not booked" cases.
- Bots and touts. Automated tools fill forms and submit faster than any human, so genuine users lose to scripts. Indian Railways has repeatedly tightened rules, including Aadhaar-based authentication for Tatkal booking announced in 2025 and restrictions on authorised agents booking in the first 30 minutes of the Tatkal window.
The design goal follows: shape demand (waiting room), make contention cheap (in-memory single-writer allocation), decouple slow dependencies (holds and async payment), and enforce fairness (bot defense and randomised admission).
High-Level Architecture
The key boundary: everything above the inventory service scales horizontally and is cheap to add capacity to. The inventory service is the scarce, serialised resource, so the architecture's job is to send it only well-formed, authenticated, deduplicated, rate-shaped booking attempts.
Deep Dive 1: The Virtual Waiting Room
A waiting room is the most effective tool for synchronised spikes. Ticketing companies, sneaker drops and government portals use them. It converts an uncontrolled stampede into an orderly queue that drains at a known rate.
How it works
- Activation: from 09:55, requests to booking pages for Tatkal train-classes are redirected to a lightweight waiting page served from the CDN. It is static HTML plus a small script, so it costs almost nothing to serve to millions.
- Queue ticket: each logged-in user gets one signed ticket (user id, position, issue time). One per user, not per tab: duplicate tabs get the same ticket.
- Randomised start: everyone who joins before 10:00:00 is assigned a random position among early arrivals at 10:00 (a lottery), instead of first-come-first-served by milliseconds. Arrivals after 10:00 queue behind in FIFO order. This removes the incentive to hammer refresh and neutralises sub-second bot advantages.
- Admission rate: a controller advances an "admitted up to position N" counter based on the booking engine's real-time health: e.g. admit 2,000 users per 10 seconds, slowing if latency rises.
- Polling: the waiting page polls a CDN-cacheable endpoint for the current admitted position every few seconds (with jitter) and shows "You are number 4,200; about 3 minutes".
- Admission pass: once admitted, the user exchanges the ticket for a short-lived pass (say 10 minutes) required by booking APIs. Expired passes go back to the queue.
import base64
import hashlib
import hmac
import json
import secrets
import time
KEY = secrets.token_bytes(32) # shared by waiting-room edge and booking service
def _sign(payload: dict) -> str:
body = base64.urlsafe_b64encode(json.dumps(payload, separators=(",", ":")).encode())
sig = hmac.new(KEY, body, hashlib.sha256).hexdigest()
return body.decode() + "." + sig
def _verify(token: str) -> dict:
body, sig = token.rsplit(".", 1)
if not hmac.compare_digest(sig, hmac.new(KEY, body.encode(), hashlib.sha256).hexdigest()):
raise PermissionError("tampered token")
return json.loads(base64.urlsafe_b64decode(body))
def queue_ticket(user_id: str, position: int) -> str:
return _sign({"u": user_id, "pos": position, "iat": int(time.time())})
def admission_pass(ticket: str, admitted_upto: int, ttl_s: int = 600) -> str:
t = _verify(ticket)
if t["pos"] > admitted_upto:
raise PermissionError(f"still waiting: {t['pos'] - admitted_upto} ahead of you")
return _sign({"u": t["u"], "adm": True, "exp": int(time.time()) + ttl_s})
def check_pass(token: str, user_id: str) -> bool:
p = _verify(token)
return p.get("adm") is True and p["u"] == user_id and p["exp"] > time.time()
Because tickets and passes are HMAC-signed, the edge and booking servers verify them without a database lookup, and users cannot forge a better position. Binding the pass to the user id stops pass sharing. The admitted-position counter is one tiny value, easily served from cache.
Why not just autoscale?
Autoscaling helps stateless tiers, but the inventory for one hot train-class is a single serial resource; extra web servers only increase contention on it. And autoscaling reacts in minutes while the spike lasts seconds. Pre-scaling stateless tiers and shaping demand is the correct combination.
Search and Availability Without Melting the Inventory
The pre-10:00 refresh storm is reads. Never serve them from the inventory partitions:
- The inventory service publishes availability snapshots (counts per train/date/class/quota) every second or two into a cache (Redis or an in-memory replicated store); see distributed cache design.
- Search endpoints read only snapshots. Displayed availability can be a second stale; it is advisory. The authoritative check happens at allocation.
- Train schedules, routes, fares and station lists are static and cached at the CDN.
- Before opening, Tatkal availability is a known constant; serve it statically and avoid any backend call.
- Per-user rate limits on availability refreshes (e.g. one per 2 seconds) stop scripted polling from costing anything; see rate limiter design.
Deep Dive 2: Berth Inventory and Allocation
Berths are sold per segment, not per train
Unlike a cinema seat, a train berth can be sold several times on one run: Delhi→Agra to one passenger and Agra→Bhopal to another. Model the route as legs between consecutive stops; a booking from stop a to stop b occupies legs a through b−1. Each berth stores a bitmask of occupied legs. A request fits a berth if occupied & mask(a, b) == 0.
class CoachInventory:
"""Berth occupancy per leg of the route. Leg i = stop i -> stop i+1.
A booking from stop a to stop b (a < b) occupies legs a..b-1."""
def __init__(self, berths, num_stops, preferences=None):
self.occupied = [0] * berths # bitmask of occupied legs per berth
self.legs = num_stops - 1
self.pref = preferences or {} # berth -> type ("LB", "UB", ...)
@staticmethod
def _mask(a, b):
return ((1 << (b - a)) - 1) << a # bits a..b-1
def available(self, a, b):
m = self._mask(a, b)
return sum(1 for occ in self.occupied if occ & m == 0)
def allocate(self, a, b, want=None):
"""Pick a free berth for legs a..b-1. Prefer the requested berth type, then
the berth whose existing bookings touch our range (less fragmentation)."""
m = self._mask(a, b)
best, best_score = None, None
for i, occ in enumerate(self.occupied):
if occ & m:
continue
touch = (((occ >> b) & 1) if b < self.legs else 1) + \
(((occ >> (a - 1)) & 1) if a > 0 else 1)
score = (want is not None and self.pref.get(i) == want, touch)
if best_score is None or score > best_score:
best, best_score = i, score
if best is not None:
self.occupied[best] |= m
return best
# Route: 0 NDLS - 1 AGC - 2 BPL - 3 NGP - 4 SC (4 legs), coach with 3 berths
c = CoachInventory(berths=3, num_stops=5, preferences={0: "LB", 1: "UB", 2: "LB"})
print(c.allocate(0, 2)) # NDLS->BPL -> 0
print(c.allocate(2, 4)) # BPL->SC -> 0 (fits snugly after the first booking)
print(c.allocate(1, 3, "LB")) # AGC->NGP, LB -> 2
print(c.available(0, 4)) # end-to-end berths still free -> 1
Dry run: the first booking marks legs 0 and 1 on berth 0 (0b0011). For BPL→SC (legs 2 and 3), berth 0 is free on those legs and its existing booking touches the requested range on the left, so it scores higher than empty berths: berth 0 becomes 0b1111, fully used end to end. That "snug fit" heuristic matters: without it, you would split berth 1 and lose the ability to sell a full-route NDLS→SC berth later, which is the most common request type. Real allocation rules are richer (quota-specific berth ranges, senior citizen and ladies' lower-berth rules, keeping families together, RAC side berths), but they are all scoring rules on top of this check.
#include <cstdint>
#include <iostream>
#include <vector>
// Occupancy of each berth as a bitmask over route legs (max 64 legs).
class CoachInventory {
public:
CoachInventory(int berths, int numStops) : occ_(berths, 0), legs_(numStops - 1) {}
int available(int a, int b) const {
const uint64_t m = mask(a, b);
int n = 0;
for (uint64_t o : occ_) n += (o & m) == 0;
return n;
}
// Free berth for legs a..b-1, preferring berths whose bookings touch [a, b).
int allocate(int a, int b) {
const uint64_t m = mask(a, b);
int best = -1, bestTouch = -1;
for (int i = 0; i < static_cast<int>(occ_.size()); ++i) {
const uint64_t o = occ_[i];
if (o & m) continue;
const int touch = (b < legs_ ? static_cast<int>((o >> b) & 1) : 1) +
(a > 0 ? static_cast<int>((o >> (a - 1)) & 1) : 1);
if (touch > bestTouch) { best = i; bestTouch = touch; }
}
if (best < 0) return -1; // no berth free on this range
occ_[best] |= m;
return best;
}
private:
static uint64_t mask(int a, int b) { return ((uint64_t{1} << (b - a)) - 1) << a; }
std::vector<uint64_t> occ_;
int legs_;
};
int main() {
CoachInventory c(3, 5); // 3 berths, stops 0..4 (4 legs)
int x = c.allocate(0, 2); // 0
int y = c.allocate(2, 4); // 0: berth 0 is reused for the second half
int z = c.allocate(1, 3); // 1
std::cout << x << " " << y << " " << z << " " << c.available(0, 4) << '\n'; // 0 0 1 1
}
Complexity: O(B) per allocation for B berths in the quota pool (a few hundred), each check a single AND instruction. Microseconds. The allocator is never the bottleneck; coordination around it is.
Quotas are separate pools
Each train-date-class has inventory split into quotas: General, Tatkal, Premium Tatkal, Ladies, Senior Citizen/Lower Berth, defence, and others, some released or merged at charting time. Model each quota as its own pool with its own berth set and rules. Tatkal requests touch only the Tatkal pool, which is why a "partition per (train, date, class, quota)" key falls out naturally.
Concurrency: single writer per partition
| Approach | Behaviour under 10 AM contention |
|---|---|
DB row lock per berth (SELECT … FOR UPDATE) | Thousands of transactions queue on the same rows; lock waits, deadlocks, timeouts |
SKIP LOCKED on berth rows | Better: concurrent transactions grab different free berths; still DB-bound and hard with segment logic |
| Optimistic version on the pool row | Massive retry storms; most attempts fail and retry |
| Single-writer partition (actor) in memory + durable log | Requests for one pool are queued and processed sequentially at microseconds each; no locks; easily thousands of allocations per second per pool |
The single-writer model is how exchanges and high-contention systems work (compare the matching engine in stock exchange design). Each inventory partition is owned by exactly one node (assigned via a coordination service with leases), holds its bitmasks in memory, appends every allocation and release to a replicated log before acknowledging, and rebuilds state from snapshot plus log on failover. Hot partitions get dedicated nodes before 10:00.
Bounded queues per partition
If a 100-berth pool has 30,000 attempts queued, processing them all just to say "sold out" 29,900 times wastes time. Bound each partition's queue (e.g. 3x remaining berths plus holds), reject the overflow immediately with "currently unavailable, try waitlist/alternatives", and publish "sold out" to the availability cache the moment it happens so new attempts stop at the edge.
Deep Dive 3: Holds, Payment and PNR Generation
Payment is slow and uncertain. Allocating a berth only after payment would let 50 users pay for the last berth. Allocating permanently before payment would lose berths to abandoned payments. The answer is a hold with a timeout, like BookMyShow seat locks.
| State | Entered when | Exits to |
|---|---|---|
HELD | Inventory partition allocates berths; hold expiry set (e.g. 5 to 10 minutes) | PAYMENT_PENDING, EXPIRED |
PAYMENT_PENDING | User redirected to gateway/UPI with booking id as reference | CONFIRMED, PAYMENT_FAILED, UNKNOWN |
UNKNOWN | Timeout or missing callback | Resolved by status polling and reconciliation |
CONFIRMED | Payment verified; PNR issued; berths committed | CANCELLED (later) |
EXPIRED / PAYMENT_FAILED | Hold timeout or explicit failure | Berths released to pool; refund if money was captured |
Critical rules
- Hold expiry is enforced by the inventory partition itself (a timer wheel), so released berths go back immediately and appear in the next availability snapshot.
- Late payment success: if the bank confirms after the hold expired and the berth was resold, the booking cannot be honoured: trigger an automatic refund. Size the hold window to cover typical UPI/bank latency to make this rare.
- Idempotency: the booking id is the idempotency key with the gateway; duplicate callbacks and user retries cannot create two tickets or two charges.
- Reconciliation against gateway settlement reports catches "money deducted, ticket not booked" cases and refunds them automatically; see payment system design.
- Prepaid balance option: a stored-value wallet (IRCTC has offered one) removes the external payment hop from the critical minute; the debit is a local ledger operation inside the booking transaction.
- PNR generation happens once at confirmation, using a unique, non-guessable id scheme; the ticket is committed to the system of record asynchronously through a durable queue if that system is slower than the allocator.
Deep Dive 4: Bot Defense
Tatkal's real competitor for a genuine user is not another user; it is a script. Defenses must be layered because each one can be bypassed alone.
| Layer | Control | What it defeats |
|---|---|---|
| Identity | Verified accounts (Aadhaar-based authentication for Tatkal was mandated in 2025), one active session per account | Farms of throwaway accounts |
| Account limits | Monthly ticket caps per user id; one in-flight Tatkal booking per account; limits on passengers per ticket | Touts booking at volume |
| Agent rules | Authorised agents blocked during the opening window | Professional bulk booking |
| Challenge | CAPTCHA at login and at booking, ideally risk-based (harder for suspicious sessions) | Simple scripts |
| Behaviour | Form-fill time, keystroke/mouse cadence, navigation path, time from page load to submit | Autofill extensions and headless browsers |
| Device and network | Fingerprinting, app attestation for mobile, IP reputation, data-center IP blocking, per-IP rate limits | Bot farms on cloud servers |
| Post-hoc | Pattern analysis on PNRs (same payment instrument, same passenger names across accounts), cancellation of fraudulent tickets, account bans | Organised resale |
Two design principles: make defenses risk-based (low-friction for normal users, heavy for suspicious sessions), and move checks earlier (verify identity and solve challenges before 10:00 while in the waiting room, so the critical minute is not spent on CAPTCHAs).
Fairness Policies (Product Decisions with System Consequences)
- Randomised admission for early arrivals removes the millisecond race. Everyone who is ready by 10:00 has an equal chance.
- Pre-submitted intent: a more radical design lets users submit a complete booking request (train, class, passengers, payment mandate) between 09:00 and 09:59, then allocates by lottery at 10:00. This turns a stampede into a batch job: no spike at all, perfectly fair, trivially scalable. The trade-off is user expectation and the need for pre-authorised payment (e.g. UPI mandates).
- One attempt per user per train-class in the opening window, so persistent retries do not improve odds.
- Transparent outcomes: showing queue position and "sold out" status immediately reduces anxiety-driven refresh load, which is itself a capacity win.
Failure Modes and Operational Readiness
| Failure | Mitigation |
|---|---|
| Login storm at 09:55 overloads auth | Pre-scaled auth, long-lived sessions (log in earlier in the day), JWT validation at the edge |
| Inventory node dies at 10:00:05 | Standby replica tails the log; lease-based failover in seconds; waiting room pauses admission during failover |
| Payment gateway degraded | Multiple gateways with health-based routing; longer holds for in-flight payments; wallet path |
| Downstream system of record slow | Allocator confirms from its own durable log; async commit to the record system through a queue |
| Retry storms from clients | Idempotent booking ids; client backoff with jitter; per-user rate limits |
| Cache stampede on availability | Snapshots pushed (not pulled), request coalescing, static responses for sold-out pools |
Readiness practices
- Pre-warm every stateless tier and the CDN at 09:30; do not trust autoscaling for a 60-second spike.
- Place hot partitions deliberately: the busiest train-classes (predictable from history and festival calendars) get dedicated inventory nodes.
- Load test with realistic replay of last week's 10:00 traffic shape, including bots and retries.
- Game days that kill an inventory node at 10:00:10 in staging.
- Freeze deploys around the window; feature flags to switch on stricter bot rules and slower admission if needed.
Interview Playbook
- Clarify and frame (3 min): Tatkal only or general booking? Mention that demand exceeds supply, so fairness is a requirement.
- Estimate (4 min): requests/s vs successful bookings/s; per-partition contention.
- Architecture (8 min): CDN/waiting room, gateway, services, inventory partitions, system of record.
- Waiting room (7 min): tickets, randomisation, admission rate, passes.
- Inventory (10 min): segments and bitmasks, quotas, single-writer partitions, bounded queues.
- Payment and holds (6 min): state machine, expiry, refunds, reconciliation.
- Bots, fairness, failures (7 min).
Common mistakes
- "Add more servers and a load balancer" as the main answer; it ignores contention on the inventory.
- Treating a berth like a cinema seat and missing segment-based reuse.
- Locking berths in the database for every attempt at 10:00.
- No hold timeout, or holding berths until payment with no expiry.
- Ignoring bots, which is the biggest real-world fairness issue.
- Calling the system "broken" without acknowledging that scarcity guarantees most users will not get a berth.
This question pairs naturally with the Flipkart flash sale design, which faces the same "huge demand, tiny inventory, fixed start time" shape with different domain rules. For a broader list of India-flavoured questions, see system design interview questions for India.
Frequently Asked Questions
Practise India-Scale System Design
Prepflix's system design course works through IRCTC, Flipkart sale and UPI-scale designs with mock interviews that push on contention, fairness and failure handling.
Explore the Prepflix Course →