At a glance#
| Number | Value | What it drives |
|---|---|---|
| Attempts on one hot train | ~2 million in 60 s (assumed) | 4,000 requests for every seat |
| Tatkal quota | ~500 seats (assumed) | 99.975 % of attempts must fail, fast and fairly |
| Opening burst | ~280,000 req / s | assumes ~70 % land in the first 5 s |
| Whole platform, record minute | 37,410 tickets (16 Aug 2025) | ~620 / s; one unthrottled train ≈ 450× that |
| Site capacity (since Jul 2026) | 40 lakh+ enquiries vs 1.5 lakh+ bookings / min | reads dwarf writes: separate planes |
| Latency budgets | < 200 ms admit · < 1 s hold · < 15 s to PNR | reject at the edge, never in the database |
| Storage | ~700 B per PNR · ~350 KB for 500 winners | losers store 0 bytes; disk is never the bottleneck |
| Seat hold | 5 min TTL | abandoned carts give the seat back |
Architecture#
DiagramTatkal on one page: read plane, waiting room, hot pool, saga
%% title: Tatkal on one page: read plane, waiting room, hot pool, saga
flowchart TD
U@{ icon: "sd:users", label: "~2M people at 10:00:00<br/>web · app · counters · agents" } -->|"search"| RP@{ icon: "sd:cdn", label: "Read plane · CDN + cache<br/>search · fares · availability<br/>snapshots 1–2 s stale" }
U ==>|"book"| WR@{ icon: "sd:queue", label: "Virtual waiting room<br/>token · shuffled 250 ms buckets<br/>adaptive admit rate" }
WR ==> RISK["Risk gate<br/>Aadhaar OTP · agent window · velocity"]:::service
RISK ==> GATE@{ icon: "tech:redis", label: "Redis admission counter<br/>one per pool · atomic Lua" }
GATE -->|"0 left"| NO["Sold out · < 200 ms<br/>no durable write"]:::bad
GATE ==>|"~500 tickets to try"| AL["Seat allocator<br/>single writer per pool · assigns berths"]:::hot
AL ==> SAGA["Booking saga<br/>hold 5 min → pay → PNR<br/>or release + refund"]:::core
SAGA --> PG@{ icon: "sd:card", label: "Payment gateways<br/>UPI · cards · netbanking" }
SAGA --> DB@{ icon: "sd:database", label: "PNR / ticketing DB<br/>active-passive" }
SAGA --> NT@{ icon: "sd:sms", label: "SMS / push" }
PG -.-> REC["Reconciliation worker<br/>deemed pending → PNR or refund"]:::service
DB --> WL["Waitlist promotion · chart prep<br/>single writer, strict order"]:::service
class U client
class RP,GATE cache
class WR queue
class PG,NT external
class DB store- Two planes. Search can be a second stale and is rate-limited hard; booking can't be stale at all and gets its own pre-scaled capacity.
- Each (train, date, class, quota) pool is a bulkhead: its own counter, allocator and queue, so one hot Rajdhani can't slow the other ~13,000 trains.
DiagramHot pool: "can I even try?" before "give me the seat"
%% title: Hot pool: "can I even try?" before "give me the seat"
flowchart TD
REQ["Admitted request · attempt_id"]:::core --> LUA@{ icon: "tech:redis", label: "Lua script on pool counter<br/>pool:12301:date:3A:TQ · starts at 500" }
LUA --> SEEN{"attempt_id already admitted?"}
SEEN -->|"yes · retry"| SAME["Same ticket to try<br/>no second seat burned"]:::ok
SEEN -->|"no"| C{"counter > 0?"}
C -->|"no"| SOLD["SOLD_OUT · in single-digit ms"]:::bad
C ==>|"yes · DECR + remember id"| AL["Single-writer allocator<br/>ordinary transactional UPDATE"]:::hot
AL --> DB@{ icon: "sd:database", label: "Real database<br/>assigns berth numbers" }
class LUA cache
class DB storeKey flows#
DiagramPriya wins a seat: queue, gate, hold, pay, PNR
%% title: Priya wins a seat: queue, gate, hold, pay, PNR sequenceDiagram autonumber participant A as Priya's app participant W as Waiting room participant G as Pool counter participant S as Booking saga participant P as Payment gateway Note over A: 09:59:50 · search prefetched, session open A->>W: 10:00:00 · request queue token W-->>A: Token · position 84,213 · ~2 s W->>G: Admit (rate set by core health) G-->>S: Ticket to try · counter 500 → 499 S-->>A: Hold granted · TTL 5 min A->>S: Passengers + Aadhaar OTP · idempotency key S->>P: Charge (same key) P-->>S: Success S-->>A: PNR confirmed · 3A, berth 24 Note over A,P: ~8–12 s for a winner · 1,999,500 others got "no" in < 200 ms
DiagramPayment times out: park it, reconcile, never guess
%% title: Payment times out: park it, reconcile, never guess
sequenceDiagram
autonumber
participant S as Booking saga
participant P as Payment gateway
participant R as Reconciliation worker
participant G as Seat pool
participant A as Priya
S->>P: Charge (attempt ID, idempotency key)
P--xS: timeout
Note over S: mark DEEMED_PENDING · TTL job leaves this hold alone
R->>P: Status check (same attempt ID)
alt paid
P-->>R: SUCCESS
R->>A: PNR confirmed
else failed
P-->>R: FAILED
R->>G: Release seat to the pool
R->>A: Refund + clear message
else still unknown
P-->>R: PENDING
Note over R: check again in 10 min · resolve within 30 min
endDiagramAdaptive waiting room: admit only what the core can absorb
%% title: Adaptive waiting room: admit only what the core can absorb
sequenceDiagram
autonumber
participant C as Client
participant W as Waiting room
participant B as Booking core
C->>W: Connect before 10:00:00
Note over W: one slot per session · shuffle inside 250 ms buckets
loop every 1 s
B-)W: DB latency · CPU · payment success
Note over W: healthy → 1,000/s · DB > 50 ms → halve · floor 100/s
end
W-->>C: Admitted in small batches
W-->>C: or "sold out" / "position 340,201", in msData model#
DiagramCore entities
%% title: Core entities
classDiagram
class Pool {
+poolKey : train · date · class · quota
+seatsTotal : int · about 500
+counter : int · Redis
+opensAt : 10 AM AC · 11 AM non-AC
}
class BookingAttempt {
+attemptId : UUID
+idempotencyKey : string · one per attempt
+poolKey : string
+state : BookingState
+seatRef : berth
+paymentRef : string
+holdExpiresAt : timestamp · plus 5 min
}
class PNR {
+pnr : string · the PNR number
+passengers : 1 to 6
+berths : coach · berth
+status : CNF / RAC / WL
+paymentRef : string
}
class QueueTicket {
+sessionId : string · one active slot
+bucket : 250 ms window
+shuffleKey : hash of session + bucket
}
class WaitlistEntry {
+pnr : string
+position : int · strict order
}
Pool "1" --> "many" BookingAttempt : admits
BookingAttempt "1" --> "0..1" PNR : confirms as
QueueTicket "1" --> "0..1" BookingAttempt : becomes
Pool "1" --> "many" WaitlistEntry : orders- The pool key
train:date:class:quotapartitions everything; the hottest pools get dedicated cache shards. - ~500–700 B per PNR: ~1 GB a day platform-wide, ~2.5 TB for 7 years of bookings. Rejected attempts store nothing.
Lifecycle#
DiagramBooking attempt: a saga with a TTL
%% title: Booking attempt: a saga with a TTL stateDiagram-v2 [*] --> ADMITTED: through waiting room + gate ADMITTED --> SEAT_HELD: selects seat SEAT_HELD --> HOLD_EXPIRED: 5 min TTL SEAT_HELD --> HOLD_RELEASED: abandons SEAT_HELD --> PAYMENT_INITIATED: details + OTP PAYMENT_INITIATED --> PNR_CONFIRMED: paid PAYMENT_INITIATED --> COMPENSATED: failed · release + refund PAYMENT_INITIATED --> DEEMED_PENDING: timeout DEEMED_PENDING --> PNR_CONFIRMED: gateway says paid DEEMED_PENDING --> COMPENSATED: gateway says failed PNR_CONFIRMED --> [*] COMPENSATED --> [*] HOLD_EXPIRED --> [*] HOLD_RELEASED --> [*] class PNR_CONFIRMED ok class COMPENSATED,HOLD_EXPIRED,HOLD_RELEASED bad class DEEMED_PENDING queue
Decisions#
| Decision | Chose | Over | Because |
|---|---|---|---|
| Search vs booking | Physically separate planes | Shared tiers, separate endpoints | opposite failure modes; search may be 1–2 s stale |
| Hot pool | Dumb Redis counter, then a single writer | DB row lock, or 10 shards of 50 | 280K/s on one row is lock churn; shards = 10 hot rows |
| Queue order | Shuffle inside 250 ms buckets | Pure first-come | first byte wins rewards scripts, not humans |
| Admit rate | Adaptive, from core health every 1 s | Fixed rate | fixed either wastes capacity or overloads |
| Hold then pay | Saga, 5 min TTL, compensation | Hold until the user returns | abandoned tabs never call back |
| Payment timeout | DEEMED_PENDING + reconcile |
Guess success or failure | no phantom PNR, no lost rupee |
| Isolation | Bulkhead per (train, date) pool | One shared inventory cluster | one hot train never slows the rest |
When it breaks#
| Failure | What users see | Contain it with |
|---|---|---|
| Search spike shares booking infra (Dec 2024) | "e-ticketing not available for the next hour" | separate tiers; rate-limit enquiries hard |
| DB latency crosses 50 ms | longer wait in the queue | admit rate halves; waiting is cheap at the edge |
| Payment gateway times out | "pending", then PNR or refund | DEEMED_PENDING, status checks, resolve in 30 min |
| Bank confirms after the hold expired | refund with priority + clear message | expiry marks for reconciliation, never a silent loss |
| One hot train's 280K/s burst | other trains unaffected | per-pool counters, allocators, connection pools |
| Bots and bulk agents | blocked or asked for extra checks | Aadhaar OTP, no agents first 30 min, velocity + ML |
Say it in the interview#
- "It's admission control wearing a database's clothes." More database servers don't add seats; they only change how fast you say no.
- Make the hot counter dumb on purpose: one atomic check-and-decrement, no joins, no payment logic; every extra step is multiplied by 280K/s.
- Losers store zero bytes: reject at the waiting room or gate in < 200 ms; the database sees ~500 requests, not 2 million.
- Two planes, not two endpoints: 40 lakh+ enquiries a minute must never touch the 1.5 lakh+ bookings path.
- Uncertain payment is a state: a timeout becomes
DEEMED_PENDINGand reconciles; every captured rupee ends in a PNR or a refund. - One idempotency key per attempt across hold, payment and PNR, so a double tap is one charge and one seat.
- Fair, not fastest: one queue slot per session and shuffled 250 ms buckets, so a script's milliseconds buy nothing.