Design a Tatkal-style ticket booking rush

At 10:00 sharp, booking opens and about a million people try to book a few lakh seats in minutes. The design rests on a fair waiting room, seat holds that expire, consistent seat allocation per train, and a payment window that always closes before the hold expires.

Design the booking path of a railway reservation system like Tatkal. At 10:00 every morning, booking opens for tomorrow's trains and around 10 lakh users try to book at once. Seats are limited. Nobody may get a seat someone else already has, and the system must stay up.

Requirements

Functional

  • Users join a fair waiting room before opening time and are let in in order.
  • A user who is let in picks a train, class and passengers, and gets a hold on specific seats.
  • The user pays within a fixed window. On success, the hold becomes a booking with a PNR.
  • Unpaid or abandoned holds go back to inventory automatically.

Non-functional

  • About 1 million users arrive within about 2 minutes of 10:00. The waiting room absorbs about 100,000 requests per second.
  • Zero double booking: allocation is strongly consistent per train, date and class. Every request sees the latest seat count.
  • A seat hold expires after 8 minutes (its TTL, or time to live). The payment session lasts 7 minutes, so a payment can never succeed after the hold expires.
  • Booking confirmed within 2 seconds of payment success for 99% of users (p99). 99.95% availability during the rush.

Out of scope

  • Fare rules, quota rules and concessions.
  • Cancellations, refunds of booked tickets and waitlist promotion (follow-up).
  • Bot detection details beyond per-user limits.

Estimate

  • Arrivals: 1M users, most in the first 30 s = ~33,000 joins/s; with refreshes and retries x3 = ~100,000 requests/s at the edge.
  • Supply: say 3,000 trains x 3 classes x ~30 quota seats = ~270,000 seats. Demand is several times supply, so most users will fail, and the system must say so quickly.
  • Hot trains: a popular train may see 50,000 users for 90 seats. Per-train contention, not total traffic, is the real bottleneck.
  • Admission: let ~3,000 users/s into the booking flow. Each spends ~60 s entering passengers, so ~180,000 sessions are in flight, well within a stateless service tier.
  • Holds: at most ~3,000 hold transactions/s spread across trains; a single-row conditional update per train-class is ~1 ms of row lock, so even a hot train can serialise roughly 500 to 1,000 holds/s.
  • Payments: ~270,000 holds paid over ~10 minutes = ~450 payment callbacks/s.

API

  • POST /waiting-room/join {opening: "2026-10-18T10:00"} -> 202 {queueToken, position}
  • GET /waiting-room/status?token= -> 200 {position, etaS} | 200 {admitted: true, admissionToken}
  • GET /trains/{id}/availability?date=&class= -> 200 {available}
  • POST /holds {trainId, date, class, passengers[], admissionToken, idempotencyKey} -> 201 {holdId, seats[], expiresAt} | 409 sold out
  • POST /holds/{id}/payment {method: "UPI"} -> 200 {paymentRef, payBy}
  • POST /payments/webhook {paymentRef, status, signature} -> 200
  • GET /bookings/{pnr} -> 200 {status, seats}

Data model

  • inventory(train_id, date, class, available, version) PK (train_id, date, class), sharded by train_id
  • seats(train_id, date, class, seat_no, status FREE | HELD | BOOKED, hold_id, held_until)
  • holds(hold_id PK, user_id, train_id, date, class, seat_nos[], status HELD | PAID | EXPIRED, expires_at, idempotency_key UNIQUE)
  • bookings(pnr PK, hold_id UNIQUE, user_id, passengers, payment_ref UNIQUE, created_at)
  • Waiting room (in memory): sorted set per opening of queueToken -> arrival sequence; admitted tokens are signed and short-lived

A request, step by step

  1. Before 10:00, users load a static waiting-room page from the CDN and join the queue. Each user gets a signed token, and their position is kept in a sorted set. Refreshing the page does not lose your place.
  2. From 10:00 the waiting room lets tokens in, in order, at a controlled rate of about 3,000 per second. Each gets an admission token valid for a few minutes. The gateway rejects booking calls that do not carry one.
  3. The user who is let in asks for a hold. The booking service runs one transaction on the train's shard. It lowers the train-class seat counter only if enough seats remain. It marks specific seats HELD, with a hold_id and held_until set to 8 minutes from now, and saves the hold. If the train is sold out, it returns 409 (Conflict) at once.
  4. The booking service schedules an expiry message for the hold, then creates a 7-minute UPI payment session. The payment gateway confirms by webhook (a call back to our server). The service checks the signature. In one transaction it changes the seats from HELD to BOOKED, only if the hold is still HELD. Then it creates the PNR and sends the ticket.
  5. If the user never pays, the expiry message fires at 8 minutes. The sweeper, a background job, releases the seats only if the hold is still HELD. So a paid hold is never released.

Deep dives

Admission: a waiting room, not an autoscaler

Weak: let everyone hit the booking API, and autoscale. Autoscaling reacts in minutes, but the rush lasts seconds. The database behind the service cannot autoscale at all.

So the extra servers only send more competing requests to the same inventory rows. Users see timeouts and retry, which multiplies the load.

Good: rate limit, and return 429 (Too Many Requests). This protects the backend. But getting in becomes a lottery: whoever retries at the right millisecond wins. Users learn to keep pressing refresh, so the retry storm grows and it feels unfair.

Great: a virtual waiting room. Users join before opening and get a signed queue token. They see their position on a static page served from the CDN, which is cheap even at 100,000 requests per second.

At 10:00 the room lets users in, in arrival order. The rate matches what the inventory tier can actually handle, measured in load tests, not guessed. Admission tokens are short-lived and tied to one user, so they cannot be shared. Each user can have only one active session.

Trade-off: some users wait a minute or two and then learn the seats are gone. That honest, fair answer is far better than 30 minutes of errors. It also keeps the database working within its measured capacity.

Seat holds: consistent allocation with a TTL

Weak: read availability, then insert a booking. Two users read "1 seat left" and both book it. Caching availability and trusting the cache makes this worse.

Good: a conditional update. Run "UPDATE inventory SET available = available - 2 WHERE train = T AND date = D AND class = C AND available >= 2". It succeeds for exactly as many requests as there are seats.

In the same transaction, pick specific FREE seats with SELECT ... FOR UPDATE SKIP LOCKED. This locks free seats and skips seats another transaction has already locked. Mark them HELD with a hold id and an expiry. The database enforces the rule, not the application.

Great: use the same guarded transaction, sharded by train, so busy trains do not share locks with quiet ones. Then add protection for the busiest rows.

If one train's counter becomes the bottleneck, use a single-writer partition: a queue keyed by train, where one worker handles all holds for that train in order. There is no waiting for locks.

The availability shown in search is a cached hint that may be out of date. Only the hold transaction decides. Holds expire by TTL. A scheduled release enforces this, and every query also checks held_until.

Trade-off: strong consistency on the hold path limits how many holds per second one train can handle. One row, or one partition, processes them one at a time. That is fine, because a train has only 90 seats. Being correct matters far more than being faster.

Payment timeout versus hold expiry

The worst bug in booking systems is a payment that succeeds after the seat was released and sold to someone else.

Weak: hold the seat for 8 minutes, but let the payment take as long as the gateway allows. A user who pays at minute 9 is charged for a seat another user now holds.

Good: make the payment session shorter than the hold: 7 minutes against 8. Then a payment cannot normally complete after the hold expires.

When the webhook arrives, confirm only with a guarded update ("WHERE status = HELD"). Make the webhook idempotent on payment_ref, so that when the gateway retries the webhook, you still create only one booking.

Great: also handle the rare late success, such as a delayed webhook or a bank that confirmed late. If the guarded update finds the hold EXPIRED, try to allocate new seats on the same train. If none remain, refund automatically and tell the user.

A reconciliation job compares payments against bookings every few minutes. It catches anything the webhooks missed. For a webhook that never arrives, it asks the gateway for the status of pending payments.

Trade-off: a shorter payment window means a few slow UPI approvals fail. The alternative is charging people for seats they did not get.

What breaks

  • The waiting-room state store fails. Tokens are signed, so users already let in can continue. New users briefly see "retry in a minute", served from the CDN.
  • An inventory shard's primary fails during the rush. Holds for those trains return 503 (Service Unavailable) while a replica takes over. The waiting room lets fewer users in for those trains, so retries do not pile up.
  • Payment webhooks are delayed, and holds get close to expiry. Before releasing a hold, the service asks the gateway about pending payments. Reconciliation refunds any late payment that lost its seat.
  • The expiry queue falls behind. Holds still stop counting after held_until, because every query checks it. So old holds do not block other users, even if the release is late.

What each level is expected to cover

Mid level: Uses a conditional update or a row lock so seats are never double booked. Introduces holds with an expiry. Makes payment webhooks idempotent. Sees that the 10:00 spike needs admission control.

Senior: Designs a fair waiting room sized from the numbers. Shards inventory by train and handles the busiest trains. Lines up the payment and hold timeouts. Covers late payments with refunds and reconciliation. States clearly where the system is strongly consistent and where it shows hints that may be out of date.

Common mistakes

  • Relying on autoscaling for a spike that lasts seconds and lands on one database.
  • Check-then-book allocation, or trusting cached availability.
  • A payment window longer than the hold, so users pay for seats they lost.
  • Payment webhooks that are not idempotent, so a retry creates two bookings.
  • Returning 429 (Too Many Requests) to everyone with no fairness, which rewards bots and people who keep refreshing.

Follow-up questions

  • How would you add a waitlist that moves people up automatically when someone cancels?
  • Bots are joining the waiting room with thousands of accounts. What do you change?
  • How would you let a user choose specific berths without lowering throughput?
  • How would you load test the 10:00 rush?
  • How do you show availability on search pages without hitting the inventory database?

Lessons behind this answer