Case Study 7: IRCTC Tatkal Ticket Booking under Extreme Spike
At 10 AM lakhs of people rush for limited Tatkal seats at the same moment; the system must stay fair, fast and bot-proof.
C.1 Requirements
Functional requirements:
- The user logs in, searches trains and sees seat availability.
- Accept booking requests the moment the Tatkal window opens (AC 10:00, Non-AC 11:00).
- Virtual waiting room: keep excess users in a queue and let them in when their turn comes.
- Temporarily lock the seat until payment completes (for example 10-15 min).
- Take payment through a payment gateway / UPI and generate the PNR on success.
- On payment failure or timeout, release the seat lock and return the seat to the pool.
- Stop misuse by bots and agents (CAPTCHA, rate limit, per-user booking limit).
- Send the PNR confirmation by SMS / email.
Non-functional requirements:
- Handle 10-15 lakh concurrent users at peak without crashing (graceful degradation via a queue).
- Zero overselling: one seat to exactly one passenger; strong consistency on inventory.
- Fairness: first come, first served (FIFO queue); bots must not get an advantage.
- Seat lock + booking API p99 latency < 500 ms once the user is let in.
- Exactly-once booking with payment: no double charge or double PNR.
- 99.95% availability during the Tatkal window.
C.2 Estimation (back-of-the-envelope)
| Item | Number | How it is derived |
|---|---|---|
| Peak concurrent users | ~15 lakh | Most Tatkal traffic arrives in the first 2-5 minutes of the window (assumption). |
| Tatkal seats per day | ~2-3 lakh berths | Assumption: ~3000 trains x ~80-100 Tatkal berths on average. |
| Peak incoming requests | ~5 lakh req/sec | 15 lakh users x a refresh/poll every 3 sec = 5 lakh RPS at the edge. |
| Admit rate from waiting room | ~5,000 users/sec | The booking core can safely handle only this much; the rest wait in the queue. |
| Seat lock writes | ~3,000/sec peak | About 60% of admitted users select a seat and lock it. |
| Booking storage per day | ~1.5 GB | ~15 lakh total bookings/day x ~1 KB record. |
| Redis lock memory | ~300 MB | 3 lakh active locks x ~1 KB (key + passenger meta); fits easily in a single cluster. |
C.3 API design
| Endpoint | Purpose |
|---|---|
| GET /v1/trains/search?from&to&date | View trains and Tatkal availability (cached) |
| POST /v1/queue/join | Get a token in the waiting room; the response has position + ETA |
| GET /v1/queue/status?token | Poll the queue position; when your turn comes you get a signed admit-token |
| POST /v1/bookings/hold | Lock the seat with the admit-token (Idempotency-Key header) |
| POST /v1/bookings/{id}/pay | Initiate payment; returns the gateway redirect URL |
| POST /v1/payments/callback | Gateway webhook: confirm or release the booking on success/fail |
| GET /v1/pnr/{pnr} | View the PNR status |
C.4 Data model
| Table / Store | Fields | Why |
|---|---|---|
| seat_inventory (Redis + RDBMS, sharded by train_id+date) | train_id, journey_date, coach, berth_no, quota, status (FREE/HELD/BOOKED), hold_id, version | Each train-date's inventory sits in a separate shard so one hot train does not slow the whole DB. |
| seat_holds (Redis with TTL) | hold_id, user_id, train_id, date, berths[], expires_at | On a payment timeout the lock expires by itself through the TTL. |
| bookings (RDBMS, sharded by user_id) | booking_id PK, pnr, user_id, train_id, date, passengers JSON, amount, status, idempotency_key UNIQUE | ACID is needed for payment and PNR consistency. |
| queue_tokens (Redis sorted set) | token_id, user_id, score = arrival_ts, state | A ZSET gives FIFO order and rank in O(log N). |
| payments (RDBMS) | payment_id, booking_id, gateway_txn_id, amount, status, created_at | An audit trail for reconciliation and refunds. |
C.5 High-level architecture
- 1.Clients: Mobile App, Web Browser, Bots (scripts, auto-refresh)
- 2.Edge: CDN (static pages, queue status cached) and WAF + Bot Manager (IP reputation, fingerprint, behaviour); bots blocked at the edge, suspicious users get CAPTCHA
- 3.Global Load Balancer -> Virtual Waiting Room (POST /queue/join, FIFO token, admit N/sec) -> API Gateway + Rate Limiter (admit-token verify, per-user / IP limit)
- 4.Services: Search Service (cached availability), Booking Service (hold, confirm, release), PNR Service, Payment Service (idempotent, webhook) -> external Payment Gateway (UPI / card)
- 5.Data: Redis (locks via Lua, queue ZSET, seat_holds TTL 15 min, availability cache), Inventory DB (sharded train_id + date), Bookings DB (bookings, payments)
- 6.Kafka: booking_confirmed, payment_failed -> Notification Worker (SMS / email), Reconciliation Job (settlement, auto-refund); Hold Expiry Worker (TTL expire -> hold EXPIRED, seat FREE)
Booking path: edge -> waiting room -> admit-token -> hold -> pay -> confirm -> notify. Funnel: ~5 lakh req/s at the edge (15 lakh users) -> ~5,000 admit/s from the waiting room -> ~3,000 locks/s (Redis seat hold); 2-3 lakh Tatkal berths/day.
- Edge: CDN static pages + cached queue status; the WAF + Bot Manager blocks bots at the edge and gives suspicious users a CAPTCHA.
- LB -> Virtual Waiting Room: a token in a Redis ZSET; it admits at a fixed rate of ~5,000/s and issues a signed JWT admit-token.
- The API Gateway verifies the token and passes the request to Booking Service; seat lock via Redis Lua with a 15 min TTL, the hold in the Inventory DB.
- Payment Service takes payment via the gateway; on the webhook, BOOKED + PNR in the Bookings DB, with notification and reconciliation through Kafka.
- The Hold Expiry Worker sets the seat back to FREE when the Redis key expires.
| Component | What it does |
|---|---|
| Virtual Waiting Room | When the window opens it gives everyone a random/FIFO token and admits users at a fixed rate. Issues a signed JWT admit-token. |
| WAF + Bot Manager | Blocks automated scripts at the edge using IP reputation, device fingerprint, behaviour analysis and CAPTCHA. |
| Search Service | Serves the train list and approximate availability from the Redis/CDN cache, so the DB takes no load. |
| Booking Service | Seat hold, confirm and release logic. Locks the seat atomically with a Redis Lua script, then persists to the DB. |
| Payment Service | Integrates with the gateway, initiates payment with an idempotency key and verifies the webhook. |
| Hold Expiry Worker | Scans expired holds, sets the seat back to FREE and shows the inventory to waiting users. |
| Kafka | Delivers events like booking-confirmed and payment-failed asynchronously to notification, analytics and reconciliation. |
C.6 Main flows
Getting in through the waiting room:
- At 9:58 the user logs in; the WAF + CAPTCHA do the bot check.
- At 10:00 the user does POST /queue/join and a token with the arrival time is added to the Redis ZSET.
- Pre-window arrivals are randomized so those who refreshed early get no unfair advantage.
- The client polls the status every 5-10 sec (a lightweight, CDN-cacheable endpoint).
- Every second the admission controller marks the top N tokens ADMITTED (N = backend capacity).
- The admitted user gets a short-lived signed admit-token (for example valid for 10 min).
- The API Gateway verifies the admit-token on every booking call; requests without a token are rejected.
Seat lock and payment flow:
- The user selects train, class and passengers and sends POST /bookings/hold with an Idempotency-Key.
- Booking Service runs a Redis Lua script: check free berths + mark HELD + SET the hold key with a 15 min TTL, all atomic.
- The hold record is saved in the DB in the PENDING_PAYMENT state.
- The user goes to the payment page; Payment Service creates an order at the gateway.
- The gateway webhook arrives: verify the signature, dedup on payment_id.
- On success, in a DB transaction the seats become BOOKED, the PNR is generated and the hold is deleted.
- A booking_confirmed event goes on Kafka and the Notification Worker sends SMS/email.
- If the TTL expired and the payment arrived late, an auto-refund is triggered.
Payment timeout flow:
- The user does not complete payment within 15 min.
- The Redis key expires; a keyspace event or the Hold Expiry Worker detects it.
- The worker marks the hold EXPIRED in the DB and returns the seats to the inventory as FREE (with a version check).
- The availability cache is updated and the next admitted users see the seat.
- If a late payment success arrives afterwards, the reconciliation job initiates a refund.
C.7 Deep dives
- 1.15 lakh users: 9:58 login + CAPTCHA, 10:00 queue/join
- 2.Redis ZSET queue_tokens, score = arrival_ts: rank 1 u_881 ADMITTED ... rank 5000 u_771 ADMITTED, rank 5001 u_093 WAITING ...
- 3.Admission Controller: top N every second, N = 5,000
- 4.Signed admit-token: JWT, user-bound, valid ~10 min
- 5.API Gateway: verifies the token on every booking call; no token = reject, so the backend is never overloaded
Pre-window arrivals are randomized: no unfair advantage for early refreshers. Status poll every 5-10 s, CDN-cacheable.
- 1.1. POST /bookings/hold: Idempotency-Key + admit-token
- 2.2. Redis Lua atomic: FREE? -> HELD + hold key TTL 15 min
- 3.3. DB: hold PENDING_PAYMENT; Inventory + version column
- 4.4. Payment Service: order created at the gateway
- 5.5. Webhook: signature verify, payment_id dedup
- 6.6. DB txn: BOOKED + PNR, hold delete -> Kafka booking_confirmed
Two people on the same berth: only one gets HELD in the Lua check-and-set. The DB also has an optimistic lock on status + version, so no overselling. Hot train: a separate Redis shard + coach-level partition.
- 1.FREE -> Lua hold on inventory -> HELD (hold_id, TTL 15 min)
- 2.HELD -> pay success -> BOOKED (PNR)
- 3.HELD -> 15 min, no pay -> EXPIRED (hold record, Hold Expiry Worker, version check) -> seat FREE
- 4.The next admitted users see the seat: availability cache update
If a payment succeeds late, after the TTL, the reconciliation job matches the settlement file and auto-refunds.
Problem: thundering herd at 10:00, backend crash. The virtual waiting room absorbs the traffic and admits at a fixed rate. Static pages come from the CDN, queue status is cacheable. The backend never gets more load than its capacity.
Problem: overselling / the same berth to two people. The check-and-set in the Redis Lua script is atomic. The DB also has optimistic locking on the status + version column. Inventory is sharded by train_id+date so lock contention stays limited to one shard.
Problem: fairness and bot protection. Bot Manager (fingerprint, behaviour), CAPTCHA, per-user and per-IP rate limits, limited bookings per user per window, and randomizing pre-window arrivals. The admit-token is bound to the user and cannot be shared.
Problem: payment succeeds but the booking fails (or the reverse). Idempotency key + a payment state machine. Both the webhook and the client callback are idempotent. Daily reconciliation matches the gateway settlement file and auto-refunds on a mismatch.
Problem: a hot train (everyone piles onto one popular train). Put that train's inventory on a separate Redis shard. Coach-level partitioning spreads the locks. Read availability from an eventually consistent cache; only the hold gets a strong check.
C.8 Trade-offs
| Decision | What was chosen and why |
|---|---|
| Waiting room vs sirf autoscaling | Chose the waiting room: the spike arrives in seconds, autoscaling is not that fast, and the DB does not scale. |
| Redis lock vs DB row lock | Redis for the fast atomic lock, the DB as the source of truth. If Redis crashes, the DB version check prevents overselling. |
| Strict FIFO vs randomized entry | Users arriving before the window opens are randomized, after that FIFO; this reduces the advantage of refresh-spam and bots. |
| Lock TTL lamba vs chhota | Around 15 min: with a shorter TTL genuine users cannot finish paying, with a longer one seats stay blocked for nothing. |
C.9 Interview tips
- Say first that the problem is not read-heaviness but write contention and a spike; that is why the waiting room is the most important piece.
- Explain the seat hold as a state machine: FREE -> HELD -> BOOKED / EXPIRED.
- Two layers against overselling: a Redis atomic lock + DB optimistic locking.
- Be sure to mention the payment timeout and late-payment refund edge case.
- Present bot protection as a combination of WAF, CAPTCHA, rate limits and a per-user limit.