Building a scalable, fault-tolerant ticket booking platform — seat locking, payment processing, concurrency control, and real-time inventory at massive scale
Table of Contents
- Introduction — The Ticket Booking Landscape
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope Calculations
- Data Model & Storage Schema
- API Design
- High-Level Architecture
- Seat Inventory & Real-Time Availability
- Seat Locking & Concurrency Control
- Database Design & Sharding
- Caching Strategy
- Payment Processing & Transactions
- Notification System
- Search & Discovery
- Security & Fraud Prevention
- Monitoring & Observability
- Multi-Region Design
- Cost Estimation
- Edge Cases
- Interview Q&A
- Full C# Implementation
- Conclusion
1. Introduction — The Ticket Booking Landscape
BookMyShow is India's largest online ticketing platform, processing over 150 million transactions per month and serving more than 50 million monthly active users. During blockbuster movie releases like RRR or Pathaan, the platform experiences traffic spikes of 200,000+ concurrent users fighting for limited seats in popular theaters. The system must handle peak loads of 15,000+ transactions per second while maintaining sub-second response times for seat selection and under 5-second end-to-end booking completion. This is not a simple e-commerce checkout problem — it is a real-time inventory management system where two users must never be allowed to book the same seat, where payment failures must not leave ghost reservations, and where the entire flow from seat selection to ticket generation must complete atomically.
Unlike standard e-commerce where overselling can be compensated with refunds or back-orders, ticket booking has a strict one-to-one mapping: each seat in a theater belongs to exactly one show at one time. Selling the same seat twice is a catastrophic failure that destroys customer trust and creates operational nightmares. The system must therefore guarantee seat uniqueness through distributed locking, handle flash-sale traffic patterns where millions of users simultaneously attempt to book seats for a single show, process payments reliably across multiple payment gateways, and deliver tickets via email, SMS, and in-app notifications within seconds of booking completion. The architectural challenges compound when you consider that BookMyShow handles movies, plays, concerts, sports events, and more — each with different seating configurations, pricing models, and booking rules.
Why This Design Is Unique
Most system design interviews focus on either read-heavy or write-heavy systems. A ticket booking platform is a mixed workload that shifts dramatically based on context. During browsing and search, it is read-heavy — millions of users browsing showtimes, movie details, and theater listings. During the booking window, it shifts to write-heavy — thousands of concurrent seat-lock attempts, payment processing, and ticket generation for the same show. During flash sales for popular events, it becomes a hot-spot write system where a single show's seat inventory becomes a contention point for tens of thousands of concurrent requests. This requires a fundamentally different approach than designing a URL shortener, a social media feed, or a chat system.
The key insight that separates a senior engineer's design from a junior one is understanding that the seat inventory is a shared mutable resource that requires careful coordination. You cannot simply decrement a counter in a database — you must handle the full lifecycle of a seat reservation: tentative lock, payment processing, confirmed booking, and timeout-based release. Each stage has failure modes that must be handled gracefully. A user who locks a seat but fails to pay must not block other users indefinitely. A payment that succeeds but whose confirmation notification is lost must not result in a charged user without a ticket. These edge cases are where the real engineering complexity lies, and they are exactly what interviewers are testing.
2. Functional & Non-Functional Requirements
Functional Requirements
| # | Requirement | Priority | Details |
|---|---|---|---|
| F1 | Browse movies & events | Must | List movies, shows, trailers, ratings, and reviews by city |
| F2 | Search showtimes | Must | Find available shows by movie, date, language, format, and theater |
| F3 | Seat selection | Must | Interactive seat map with real-time availability, pricing tiers, and selection |
| F4 | Seat locking | Must | Temporarily hold selected seats during checkout with timeout |
| F5 | Payment processing | Must | Support UPI, credit/debit cards, wallets, net banking, and EMI |
| F6 | Booking confirmation | Must | Generate ticket with QR code, send confirmation via email/SMS |
| F7 | Booking history | Must | View past and upcoming bookings with ticket details |
| F8 | Cancel booking | Should | Cancel booking with refund based on cancellation policy |
| F9 | Price calculation | Must | Dynamic pricing based on seat type, show time, demand, and day |
| F10 | Multiple payment retries | Should | Retry failed payments without re-entering seat selection |
| F11 | Watchlist & alerts | Nice | Notify users when tickets go on sale for bookmarked events |
| F12 | Food & beverage pre-order | Nice | Order snacks during checkout for theater pickup |
Non-Functional Requirements
| # | Requirement | Target | Rationale |
|---|---|---|---|
| NF1 | Availability | 99.99% | Downtime during blockbuster releases causes massive revenue loss and brand damage |
| NF2 | Read latency (p99) | < 200ms | Browse and search must feel instantaneous for good UX |
| NF3 | Write latency (p99) | < 500ms | Seat lock and booking confirmation must be fast to prevent user abandonment |
| NF4 | Seat uniqueness | 100% guaranteed | No double-booking — zero tolerance for selling the same seat twice |
| NF5 | Consistency | Strong for bookings | Payment and booking must be strongly consistent; browsing can be eventually consistent |
| NF6 | Idempotency | All write operations | Network retries must not create duplicate bookings or charges |
| NF7 | Flash sale handling | 15K+ TPS | Peak load during blockbuster releases and concert on-sale events |
| NF8 | Data retention | 7 years | Financial records and booking history for compliance |
3. Capacity Estimation & Back-of-Envelope Calculations
Traffic Estimates
| Metric | Value | Calculation |
|---|---|---|
| Monthly active users | 50 million | Given (BookMyShow scale) |
| Daily active users | 10 million | 50M * 0.2 daily active ratio |
| Peak concurrent users | 500,000 | 10M * 0.05 peak ratio |
| Bookings per day (avg) | 500,000 | 10M DAU * 0.05 conversion rate |
| Bookings per second (avg) | ~6 TPS | 500K / 86,400 |
| Peak TPS (flash sale) | 15,000 TPS | Blockbuster release peak |
| Read-to-write ratio | 100:1 | For every booking, 100 browse/search requests |
| Search QPS (peak) | 300,000 | 15,000 TPS * 20 read multiplier |
Storage Estimates
| Data Type | Size per Record | Records per Day | Daily Storage |
|---|---|---|---|
| Booking records | 2 KB | 500,000 | ~1 GB |
| Seat inventory updates | 200 B | 10,000,000 | ~2 GB |
| Payment records | 1 KB | 500,000 | ~0.5 GB |
| Search/audit logs | 500 B | 50,000,000 | ~25 GB |
| Total daily | ~28.5 GB |
Bandwidth Estimates
The total bandwidth during peak is approximately 15,000 TPS * 10 KB average response = 150 MB/s inbound for writes. For reads at 300,000 QPS * 5 KB average response = 1.5 GB/s. Combined peak bandwidth is approximately 1.65 GB/s. This requires load balancers and application servers capable of handling multi-gigabit throughput, with CDN serving static assets (images, movie posters, CSS, JS) to offload approximately 70% of read traffic from origin servers.
4. Data Model & Storage Schema
The data model must capture the hierarchical relationship between cities, venues, screens, shows, seats, and bookings. Each entity has specific access patterns that influence whether it should be stored in a relational database, a document store, or a cache layer.
Core Entities
| Entity | Storage | Access Pattern | Partition Key |
|---|---|---|---|
| City | PostgreSQL | Read-heavy, rarely changes | city_id |
| Venue / Theater | PostgreSQL | Read-heavy, geo-spatial queries | city_id |
| Screen | PostgreSQL | Read-heavy, linked to venue | venue_id |
| Movie / Event | PostgreSQL + Elasticsearch | Search, filtered browsing | movie_id |
| Show | PostgreSQL | Hot reads, frequently queried | movie_id + date |
| Seat Layout | Redis (JSON) + PostgreSQL | Real-time reads during selection | screen_id |
| Seat Inventory | Redis (hash) + PostgreSQL | Hot writes, contention point | show_id |
| Booking | PostgreSQL (primary) + Cassandra (history) | Write-heavy during flash sales | user_id + booking_id |
| Payment | PostgreSQL | ACID transactions, audit trail | booking_id |
| Pricing Rule | Redis + PostgreSQL | Real-time price computation | show_id |
PostgreSQL Schema — Core Tables
SQL
CREATE TABLE cities (
city_id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
state VARCHAR(100),
country VARCHAR(100) DEFAULT 'India',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE venues (
venue_id BIGSERIAL PRIMARY KEY,
city_id BIGINT REFERENCES cities(city_id),
name VARCHAR(200) NOT NULL,
address TEXT,
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
venue_type VARCHAR(50) CHECK (venue_type IN ('MOVIE', 'CONCERT', 'SPORTS', 'PLAY')),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE screens (
screen_id BIGSERIAL PRIMARY KEY,
venue_id BIGINT REFERENCES venues(venue_id),
name VARCHAR(100) NOT NULL,
screen_type VARCHAR(50) CHECK (screen_type IN ('IMAX', '4DX', 'DOLBY_ATMOS', 'STD', 'PREMIUM')),
total_rows SMALLINT NOT NULL,
total_columns SMALLINT NOT NULL,
total_seats INT GENERATED ALWAYS AS (total_rows * total_columns) STORED
);
CREATE TABLE movies (
movie_id BIGSERIAL PRIMARY KEY,
title VARCHAR(300) NOT NULL,
language VARCHAR(50) NOT NULL,
genre VARCHAR(100),
duration_min SMALLINT NOT NULL,
rating VARCHAR(10) CHECK (rating IN ('U', 'U/A', 'A', 'U/A 13+')),
release_date DATE NOT NULL,
poster_url TEXT,
trailer_url TEXT,
synopsis TEXT,
is_active BOOLEAN DEFAULT TRUE
);
CREATE TABLE shows (
show_id BIGSERIAL PRIMARY KEY,
movie_id BIGINT REFERENCES movies(movie_id),
screen_id BIGINT REFERENCES screens(screen_id),
show_date DATE NOT NULL,
show_time TIME NOT NULL,
format VARCHAR(20) DEFAULT '2D',
language VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'CANCELLED', 'COMPLETED')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (screen_id, show_date, show_time)
);
CREATE TABLE seat_layouts (
seat_id BIGSERIAL PRIMARY KEY,
screen_id BIGINT REFERENCES screens(screen_id),
row_label VARCHAR(5) NOT NULL,
seat_number SMALLINT NOT NULL,
seat_type VARCHAR(30) CHECK (seat_type IN ('RECLINER', 'PREMIUM', 'GOLD', 'SILVER', 'BRONZE', 'STANDARD')),
base_price DECIMAL(10, 2) NOT NULL,
is_recliner BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
UNIQUE (screen_id, row_label, seat_number)
);
CREATE TABLE seat_inventory (
inventory_id BIGSERIAL PRIMARY KEY,
show_id BIGINT REFERENCES shows(show_id),
seat_id BIGINT REFERENCES seat_layouts(seat_id),
status VARCHAR(20) DEFAULT 'AVAILABLE'
CHECK (status IN ('AVAILABLE', 'LOCKED', 'BOOKED', 'BLOCKED')),
locked_by UUID,
locked_at TIMESTAMPTZ,
price DECIMAL(10, 2) NOT NULL,
version INT DEFAULT 1,
UNIQUE (show_id, seat_id)
);
CREATE TABLE bookings (
booking_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id BIGINT NOT NULL,
show_id BIGINT REFERENCES shows(show_id),
booking_status VARCHAR(20) DEFAULT 'PENDING'
CHECK (booking_status IN ('PENDING', 'CONFIRMED', 'CANCELLED', 'REFUNDED', 'EXPIRED')),
total_amount DECIMAL(10, 2) NOT NULL,
convenience_fee DECIMAL(10, 2) DEFAULT 0,
discount_amount DECIMAL(10, 2) DEFAULT 0,
final_amount DECIMAL(10, 2) GENERATED ALWAYS AS (total_amount + convenience_fee - discount_amount) STORED,
booking_type VARCHAR(20) DEFAULT 'MOVIE',
created_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ
);
CREATE TABLE booking_seats (
id BIGSERIAL PRIMARY KEY,
booking_id UUID REFERENCES bookings(booking_id),
show_id BIGINT NOT NULL,
seat_id BIGINT NOT NULL,
row_label VARCHAR(5) NOT NULL,
seat_number SMALLINT NOT NULL,
seat_type VARCHAR(30),
price DECIMAL(10, 2) NOT NULL
);
CREATE TABLE payments (
payment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id UUID REFERENCES bookings(booking_id),
user_id BIGINT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
payment_method VARCHAR(30) CHECK (payment_method IN ('UPI', 'CARD', 'WALLET', 'NET_BANKING', 'EMI')),
payment_gateway VARCHAR(50),
gateway_txn_id VARCHAR(100),
status VARCHAR(20) DEFAULT 'PENDING'
CHECK (status IN ('PENDING', 'PROCESSING', 'SUCCESS', 'FAILED', 'REFUNDED')),
idempotency_key UUID UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
Redis Data Structures
Redis
-- Seat inventory hash for a show (real-time status)
HSET show:12345:seats A1 AVAILABLE A2 LOCKED A3 BOOKED B1 AVAILABLE ...
-- Seat lock with TTL (auto-expire after 10 minutes)
SET lock:show:12345:seat:A2 "user-uuid-789" EX 600
-- Show seat count cache
HSET show:12345:meta total_seats 350 available_seats 187 locked_seats 12 booked_seats 151
-- Price cache per show
HSET show:12345:prices RECLINER 450.00 PREMIUM 300.00 GOLD 250.00 STANDARD 150.00
-- User booking cache
ZADD user:789:bookings 1689123456 "booking-uuid-123"
-- Bloom filter for booking idempotency
BF.ADD booking:idempotency "idempotency-key-abc"
5. API Design
All APIs follow RESTful conventions with JSON payloads. Authentication uses JWT tokens with short-lived access tokens (15 minutes) and long-lived refresh tokens (30 days). Every write operation requires an idempotency key in the request header to prevent duplicate processing.
Browse & Search APIs
HTTP
GET /api/v1/cities
GET /api/v1/movies?city_id={id}&date={date}&language={lang}&genre={genre}
GET /api/v1/movies/{movie_id}/details
GET /api/v1/movies/{movie_id}/showtimes?city_id={id}&date={date}
GET /api/v1/venues/{venue_id}/screens/{screen_id}/layout?show_id={id}
Booking APIs
HTTP
POST /api/v1/bookings/lock-seats
Headers: Idempotency-Key: {uuid}, Authorization: Bearer {jwt}
Body: {
"show_id": 12345,
"seat_ids": ["A1", "A2", "A3"],
"lock_duration_sec": 600
}
Response: {
"lock_id": "lock-uuid-456",
"expires_at": "2026-07-10T14:25:00Z",
"total_amount": 750.00,
"convenience_fee": 40.00,
"seats": [...]
}
POST /api/v1/bookings/confirm
Headers: Idempotency-Key: {uuid}, Authorization: Bearer {jwt}
Body: {
"lock_id": "lock-uuid-456",
"payment_id": "payment-uuid-789"
}
Response: {
"booking_id": "booking-uuid-012",
"status": "CONFIRMED",
"ticket_url": "https://...",
"qr_code": "base64..."
}
DELETE /api/v1/bookings/locks/{lock_id}
Headers: Authorization: Bearer {jwt}
Payment APIs
HTTP
POST /api/v1/payments/initiate
Headers: Idempotency-Key: {uuid}, Authorization: Bearer {jwt}
Body: {
"booking_id": "booking-uuid-012",
"payment_method": "UPI",
"vpa": "user@upi"
}
POST /api/v1/payments/webhook
Headers: X-Gateway-Signature: {hmac}
Body: { "gateway_txn_id": "...", "status": "SUCCESS", ... }
GET /api/v1/bookings/{booking_id}/payment-status
Headers: Authorization: Bearer {jwt}
User & Booking History APIs
HTTP
GET /api/v1/users/me/bookings?status=upcoming&page=1&limit=20
GET /api/v1/bookings/{booking_id}
POST /api/v1/bookings/{booking_id}/cancel
Headers: Idempotency-Key: {uuid}
Body: { "reason": "change_of_plans" }
Idempotency-Key header. The server stores the key with a 24-hour TTL. If a request with the same key arrives, the server returns the cached response without reprocessing. This prevents double bookings from network retries and handles the exact-at-most-once delivery guarantee required for payment processing.
6. High-Level Architecture
The system follows a microservices architecture with clear separation of concerns. The most critical design decision is isolating the seat inventory service as a separate, independently scalable service because it is the primary bottleneck during flash sales.
Service Responsibilities
| Service | Responsibility | Scaling Strategy | Data Store |
|---|---|---|---|
| Movie & Event Service | Movie metadata, trailers, ratings, cast info | Horizontal, read replicas | PostgreSQL + S3 |
| Search Service | Full-text search, faceted filtering, autocomplete | Horizontal, index sharding | Elasticsearch |
| Show Service | Show scheduling, availability, timing | Horizontal with caching | PostgreSQL + Redis |
| Seat Inventory Service | Real-time seat status, locking, pricing | Aggressive horizontal, Redis-backed | Redis + PostgreSQL |
| Booking Service | Booking lifecycle, confirmation, cancellation | Horizontal, partitioned by user | PostgreSQL + Cassandra |
| Payment Service | Payment orchestration, gateway integration, refunds | Horizontal, idempotent | PostgreSQL |
| Notification Service | Email, SMS, push notifications, in-app alerts | Queue-consumers, auto-scaling | Kafka consumers |
| User Service | Auth, profiles, preferences, loyalty points | Horizontal | PostgreSQL + Redis |
7. Seat Inventory & Real-Time Availability
The seat inventory system is the heart of the ticket booking platform and the most complex component to design correctly. Unlike a simple product inventory that tracks quantity, seat inventory tracks the status of every individual seat for every show. A typical multiplex has 8-12 screens with 200-500 seats each, running 4-6 shows per day. For a chain with 500 venues, that is roughly 500 * 10 * 5 * 350 = 8.75 million seat inventory records active at any given time.
Seat Status State Machine
Redis-First Architecture for Seat Status
During peak traffic, every seat selection request reads the status of 200-500 seats for a screen. With 500 concurrent users per show, that is 250,000 seat-status reads per second for a single show. A PostgreSQL table cannot handle this read throughput with acceptable latency. The solution is a Redis-first architecture where the canonical seat status lives in Redis, and PostgreSQL serves as the durable backup for crash recovery.
The Redis data structure uses a hash map per show: show:{show_id}:seats where each field is a seat identifier (like "A1", "B15") and the value is the current status. Reading all seat statuses for a show requires a single HGETALL call, which returns in under 1ms for screens with up to 500 seats. The hash also includes metadata fields like total_seats, available_count, and last_updated to avoid additional queries for seat count displays.
Seat Layout Pre-loading Strategy
C#
public class SeatInventoryService
{
private readonly IDatabase _redis;
private readonly ShowRepository _showRepo;
public async Task PreloadSeatInventoryAsync(long showId)
{
// Load seat layout from PostgreSQL (cached, rarely changes)
var layout = await _showRepo.GetSeatLayoutAsync(showId);
var key = $"show:{showId}:seats";
// Build Redis hash from PostgreSQL layout
var entries = new HashEntry[layout.Count + 3];
for (int i = 0; i < layout.Count; i++)
{
entries[i] = new HashEntry(
$"{layout[i].RowLabel}{layout[i].SeatNumber}",
"AVAILABLE"
);
}
entries[layout.Count] = new HashEntry("total_seats", layout.Count);
entries[layout.Count + 1] = new HashEntry("available_count", layout.Count);
entries[layout.Count + 2] = new HashEntry("last_updated",
DateTimeOffset.UtcNow.ToUnixTimeSeconds());
await _redis.HashSetAsync(key, entries);
await _redis.KeyExpireAsync(key, TimeSpan.FromHours(24));
}
}
Dynamic Pricing Engine
Ticket prices are not static — they vary by seat type, show time (matinee vs. evening vs. night), day of week (weekday vs. weekend vs. holiday), demand level, and special events. The pricing engine computes the final price for each seat at the time of display and locks that price during the booking flow.
| Pricing Factor | Example | Impact |
|---|---|---|
| Seat type | Recliner vs. Standard | 3x price difference (₹450 vs. ₹150) |
| Show time | Matinee (12 PM) vs. Night (9 PM) | ±20% variation |
| Day of week | Friday evening vs. Tuesday matinee | ±30% variation |
| Demand surge | Opening weekend, blockbuster release | Up to 2x surge pricing |
| Special format | IMAX, 4DX, Dolby Atmos | 1.5x - 2.5x premium |
| Holiday | Diwali, Eid, Republic Day | +25-50% holiday surcharge |
C#
public class PricingEngine
{
public decimal CalculatePrice(SeatLayout seat, Show show, PricingRules rules)
{
decimal basePrice = seat.BasePrice;
// Time-based multiplier
decimal timeMultiplier = show.ShowTime.Hour switch
{
>= 6 and < 12 => 0.85m, // Matinee discount
>= 12 and < 17 => 1.00m, // Afternoon standard
>= 17 and >= 21 => 1.20m, // Evening premium
_ => 1.10m // Late night
};
// Day-based multiplier
decimal dayMultiplier = show.ShowDate.DayOfWeek switch
{
DayOfWeek.Monday or DayOfWeek.Tuesday or DayOfWeek.Wednesday => 0.85m,
DayOfWeek.Thursday => 0.95m,
DayOfWeek.Friday or DayOfWeek.Saturday => 1.15m,
DayOfWeek.Sunday => 1.10m,
_ => 1.00m
};
// Holiday surge
decimal holidayMultiplier = rules.IsHoliday(show.ShowDate) ? 1.35m : 1.00m;
// Demand surge (based on booking velocity)
decimal demandMultiplier = CalculateDemandSurge(show.ShowId, rules);
decimal finalPrice = basePrice * timeMultiplier * dayMultiplier
* holidayMultiplier * demandMultiplier;
return Math.Round(finalPrice, 0); // Round to nearest rupee
}
}
8. Seat Locking & Concurrency Control
Seat locking is the most critical concurrency challenge in the entire system. When a user selects seats and proceeds to checkout, those seats must be temporarily reserved — no other user can book them during the checkout window. This is fundamentally a distributed locking problem with strict requirements: the lock must be atomic, time-bounded (to prevent abandoned locks from permanently holding seats), and must recover gracefully from process crashes.
Why Simple Database Locks Fail
A naive approach would use a database SELECT ... FOR UPDATE to lock seat rows during checkout. This fails under load for three reasons. First, PostgreSQL row-level locks are held for the duration of the transaction, which includes the entire payment processing time (3-10 seconds). Holding row locks for seconds under high concurrency creates lock contention that degrades throughput from thousands of TPS to single digits. Second, if the application server crashes while holding the database lock, the lock is only released when the database detects the dead connection, which can take 30-60 seconds depending on TCP keepalive settings. Third, the lock scope is limited to a single database connection — if the checkout process spans multiple service calls, the lock must be reacquired at each step, creating race conditions.
Redis-Based Distributed Locking with TTL
The production approach uses Redis with the SET key value NX EX ttl command, which sets a key only if it does not exist (NX) with an expiration time (EX) atomically. This provides mutual exclusion with automatic lease expiration. If the application crashes, the lock automatically expires after the TTL, releasing the seats for other users. The typical lock duration is 10 minutes — long enough for the user to complete payment, short enough to prevent seats from being held indefinitely.
C#
public class SeatLockService
{
private readonly IDatabase _redis;
private readonly TimeSpan _lockDuration = TimeSpan.FromMinutes(10);
public async Task<SeatLockResult> LockSeatsAsync(
long showId, string[] seatIds, Guid userId)
{
var lockId = Guid.NewGuid();
var results = new List<SeatLockResult>();
// Atomic multi-seat lock using Redis pipeline
var tasks = seatIds.Select(async seatId =>
{
var lockKey = $"lock:show:{showId}:seat:{seatId}";
var seatKey = $"show:{showId}:seats";
// Check current status first
var currentStatus = await _redis.HashGetAsync(seatKey, seatId);
if (currentStatus == "BOOKED" || currentStatus == "LOCKED")
{
return new SeatLockResult
{
SeatId = seatId,
Success = false,
Reason = $"Seat {seatId} is already {currentStatus}"
};
}
// Attempt atomic lock with TTL
var acquired = await _redis.StringSetAsync(
lockKey,
userId.ToString(),
_lockDuration,
When.NotExists
);
if (acquired)
{
// Update seat status in the inventory hash
await _redis.HashSetAsync(seatKey, seatId, "LOCKED");
await _redis.HashIncrementAsync(seatKey, "available_count", -1);
await _redis.HashIncrementAsync(seatKey, "locked_count", 1);
return new SeatLockResult
{
SeatId = seatId,
Success = true,
LockId = lockId,
ExpiresAt = DateTimeOffset.UtcNow.Add(_lockDuration)
};
}
return new SeatLockResult
{
SeatId = seatId,
Success = false,
Reason = "Failed to acquire lock — seat may be locked by another user"
};
});
return new SeatLockResult
{
LockId = lockId,
AllLocked = results.All(r => r.Success),
Results = results
};
}
public async Task ReleaseSeatsAsync(
long showId, string[] seatIds, Guid lockId, Guid userId)
{
foreach (var seatId in seatIds)
{
var lockKey = $"lock:show:{showId}:seat:{seatId}";
var seatKey = $"show:{showId}:seats";
// Verify ownership before releasing
var lockValue = await _redis.StringGetAsync(lockKey);
if (lockValue == userId.ToString())
{
await _redis.KeyDeleteAsync(lockKey);
await _redis.HashSetAsync(seatKey, seatId, "AVAILABLE");
await _redis.HashIncrementAsync(seatKey, "available_count", 1);
await _redis.HashDecrementAsync(seatKey, "locked_count", 1);
}
}
}
}
Lock Expiration Handler
When a lock expires (user did not complete payment in time), the system must restore the seat to AVAILABLE status. This is handled by a background job that polls for expired locks and reconciles the seat inventory.
C#
public class LockExpirationWorker : BackgroundService
{
private readonly IDatabase _redis;
private readonly IServiceScopeFactory _scopeFactory;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var expiredLocks = await _redis.SetMembersAsync("locks:expiring");
foreach (var lockKey in expiredLocks)
{
// Parse show_id and seat_id from lock key
var (showId, seatId) = ParseLockKey(lockKey);
// Check if lock still exists (may have been confirmed)
var stillLocked = await _redis.KeyExistsAsync(lockKey);
if (!stillLocked)
{
// Lock expired — restore seat to AVAILABLE
var seatKey = $"show:{showId}:seats";
var currentStatus = await _redis.HashGetAsync(seatKey, seatId);
if (currentStatus == "LOCKED")
{
await _redis.HashSetAsync(seatKey, seatId, "AVAILABLE");
await _redis.HashIncrementAsync(seatKey, "available_count", 1);
await _redis.HashDecrementAsync(seatKey, "locked_count", 1);
// Publish event for audit logging
await PublishSeatReleasedEvent(showId, seatId, "LOCK_EXPIRED");
}
}
}
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
}
}
}
9. Database Design & Sharding
PostgreSQL is the primary relational database for the booking, payment, and show services. At BookMyShow scale, a single PostgreSQL instance cannot handle the read and write throughput required. The database layer uses a combination of read replicas, connection pooling, table partitioning, and selective sharding.
Sharding Strategy
| Table | Shard Key | Shard Count | Rationale |
|---|---|---|---|
| bookings | user_id | 128 | User-centric queries; all bookings for a user land on same shard |
| payments | booking_id | 128 | Co-located with bookings via consistent hashing on related keys |
| seat_inventory | show_id | 256 | Show-centric access pattern; high write volume per show |
| shows | movie_id | 64 | All shows for a movie on same shard for efficient queries |
| movies | movie_id | 16 | Relatively small table; fewer shards sufficient |
| users | user_id | 64 | User profile and auth data partitioned by user |
Connection Pooling
PgBouncer sits in front of PostgreSQL to manage connection pooling. Each microservice connects to PgBouncer with a pool of 50-100 connections, which PgBouncer multiplexes across the actual PostgreSQL connections. This prevents the "thundering herd" problem where thousands of concurrent booking requests exhaust PostgreSQL's max_connections limit. The pool mode is set to transaction-level, meaning each database connection is held only for the duration of a single transaction and then returned to the pool.
Table Partitioning for Seat Inventory
SQL
-- Partition seat_inventory by show_id range
CREATE TABLE seat_inventory (
inventory_id BIGSERIAL,
show_id BIGINT NOT NULL,
seat_id BIGINT NOT NULL,
status VARCHAR(20) DEFAULT 'AVAILABLE',
locked_by UUID,
locked_at TIMESTAMPTZ,
price DECIMAL(10, 2) NOT NULL,
version INT DEFAULT 1,
PRIMARY KEY (inventory_id, show_id)
) PARTITION BY RANGE (show_id);
-- Create partitions for each shard range
CREATE TABLE seat_inventory_p0 PARTITION OF seat_inventory
FOR VALUES FROM (0) TO (100000);
CREATE TABLE seat_inventory_p1 PARTITION OF seat_inventory
FOR VALUES FROM (100000) TO (200000);
CREATE TABLE seat_inventory_p2 PARTITION OF seat_inventory
FOR VALUES FROM (200000) TO (300000);
-- Index on show_id + seat_id for fast lookups
CREATE INDEX idx_seat_inv_show_seat
ON seat_inventory (show_id, seat_id);
-- Index on status for cleanup queries
CREATE INDEX idx_seat_inv_status
ON seat_inventory (show_id, status);
Cassandra for Booking History
While PostgreSQL handles active bookings and recent history, Cassandra stores the complete booking archive (7+ years). The Cassandra schema uses a user-partitioned design optimized for the "get user's past bookings" query pattern, which is the dominant read access for historical data.
CQL
CREATE TABLE booking_history (
user_id UUID,
booking_date DATE,
booking_id UUID,
show_date TIMESTAMP,
movie_title TEXT,
venue_name TEXT,
screen_name TEXT,
seats LIST<TEXT>,
total_amount DECIMAL,
status TEXT,
PRIMARY KEY ((user_id), booking_date, booking_id)
) WITH CLUSTERING ORDER BY (booking_date DESC, booking_id ASC)
AND default_time_to_live = 630720000; -- 7 years
10. Caching Strategy
The caching strategy for a ticket booking platform is layered and nuanced because different data has different staleness tolerances. Movie metadata and show listings can tolerate a few minutes of staleness. Seat availability must be real-time. Pricing can tolerate seconds of staleness. The design uses a three-tier cache architecture: L1 (in-memory per-instance), L2 (Redis shared), and L3 (PostgreSQL source of truth).
Cache Tiers and TTL Policies
| Data Type | L1 (In-Memory) | L2 (Redis) | Invalidation Strategy |
|---|---|---|---|
| Movie metadata | 5 min | 15 min | Time-based TTL + event-driven on update |
| Show listings | 2 min | 5 min | Time-based TTL + invalidation on show add/cancel |
| Seat layout | 30 min | 1 hour | Very rarely changes; manual invalidation |
| Seat availability | None (always read Redis) | Real-time (no TTL) | Direct Redis updates during lock/book/cancel |
| Pricing rules | 5 min | 10 min | TTL + admin panel invalidation |
| User session | Per-request | 15 min | JWT validation, sliding window renewal |
| Search results | 1 min | 3 min | Aggressive caching; background re-indexing |
C#
public class CachedShowService : IShowService
{
private readonly IDatabase _redis;
private readonly IShowRepository _dbRepo;
private readonly IMemoryCache _localCache;
public async Task<ShowDetailsDto> GetShowDetailsAsync(long showId)
{
// L1: Check in-memory cache first
var cacheKey = $"show:details:{showId}";
if (_localCache.TryGetValue(cacheKey, out ShowDetailsDto cached))
return cached;
// L2: Check Redis
var redisKey = $"show:{showId}:details";
var redisValue = await _redis.StringGetAsync(redisKey);
if (!redisValue.IsNullOrEmpty)
{
var dto = JsonSerializer.Deserialize<ShowDetailsDto>(redisValue);
_localCache.Set(cacheKey, dto, TimeSpan.FromMinutes(2));
return dto;
}
// L3: Query PostgreSQL
var freshData = await _dbRepo.GetShowDetailsAsync(showId);
if (freshData != null)
{
var serialized = JsonSerializer.Serialize(freshData);
await _redis.StringSetAsync(redisKey, serialized, TimeSpan.FromMinutes(5));
_localCache.Set(cacheKey, freshData, TimeSpan.FromMinutes(2));
}
return freshData;
}
public async Task InvalidateShowCacheAsync(long showId)
{
_localCache.Remove($"show:details:{showId}");
await _redis.KeyDeleteAsync($"show:{showId}:details");
await _redis.KeyDeleteAsync($"show:{showId}:listings");
}
}
11. Payment Processing & Transactions
Payment processing in a ticket booking system is uniquely challenging because it must coordinate across two domains: the payment gateway (external system) and the booking service (internal system). The fundamental problem is that there is no way to guarantee that both systems process a transaction atomically. A payment can succeed at the gateway but the confirmation notification can be lost, leaving the user charged but without a ticket. The design must handle this inconsistency through idempotent reconciliation.
Payment Flow State Machine
Distributed Transaction Pattern: Saga
The booking confirmation uses a choreography-based Saga pattern where each step publishes an event that triggers the next step. If any step fails, compensating events undo the previous steps. This avoids the need for a two-phase commit across services.
C#
public class BookingSaga
{
// Step 1: Lock seats (already done before payment)
// Step 2: Initiate payment
public async Task HandlePaymentInitiated(PaymentInitiatedEvent evt)
{
var payment = await _paymentService.InitiatePaymentAsync(
evt.BookingId, evt.Amount, evt.PaymentMethod);
if (payment.Status == PaymentStatus.PROCESSING)
{
// Publish event to continue saga
await _eventBus.PublishAsync(new PaymentProcessingEvent
{
BookingId = evt.BookingId,
PaymentId = payment.PaymentId
});
}
}
// Step 3: Payment gateway confirms
public async Task HandlePaymentConfirmed(PaymentConfirmedEvent evt)
{
// Confirm booking
var booking = await _bookingService.ConfirmBookingAsync(evt.BookingId);
// Publish confirmation event
await _eventBus.PublishAsync(new BookingConfirmedEvent
{
BookingId = evt.BookingId,
UserId = booking.UserId,
ShowId = booking.ShowId
});
}
// Step 4: Generate ticket and send notification
public async Task HandleBookingConfirmed(BookingConfirmedEvent evt)
{
var ticket = await _ticketService.GenerateTicketAsync(evt.BookingId);
await _notificationService.SendBookingConfirmationAsync(
evt.UserId, ticket);
}
// Compensating action: Payment failed — release seats
public async Task HandlePaymentFailed(PaymentFailedEvent evt)
{
// Release locked seats
await _seatLockService.ReleaseSeatsForBookingAsync(evt.BookingId);
// Mark booking as expired/failed
await _bookingService.MarkBookingFailedAsync(evt.BookingId);
// Notify user
await _notificationService.SendPaymentFailedAsync(
evt.UserId, evt.BookingId);
}
}
Payment Gateway Integration
| Gateway | Payment Methods | Settlement | Failure Handling |
|---|---|---|---|
| Razorpay | UPI, Cards, Wallets, Net Banking | T+1 day | Auto-retry once, then failover to backup gateway |
| PayU | UPI, Cards, EMI, Pay Later | T+2 days | Immediate failover on timeout |
| Paytm | UPI, Wallet, Cards | T+1 day | Retry with exponential backoff |
Idempotent Payment Processing
C#
public class PaymentService
{
private readonly IPaymentGateway _gateway;
private readonly PaymentRepository _repo;
public async Task<PaymentResult> ProcessPaymentAsync(
ProcessPaymentCommand cmd)
{
// Check idempotency first
var existing = await _repo.GetByIdempotencyKeyAsync(cmd.IdempotencyKey);
if (existing != null)
{
return new PaymentResult
{
PaymentId = existing.PaymentId,
Status = existing.Status,
IsDuplicate = true
};
}
// Create payment record
var payment = new Payment
{
PaymentId = Guid.NewGuid(),
BookingId = cmd.BookingId,
Amount = cmd.Amount,
PaymentMethod = cmd.PaymentMethod,
IdempotencyKey = cmd.IdempotencyKey,
Status = PaymentStatus.PROCESSING,
CreatedAt = DateTime.UtcNow
};
await _repo.SaveAsync(payment);
// Call payment gateway with idempotency key
var gatewayResult = await _gateway.ChargeAsync(
payment.PaymentId,
payment.Amount,
payment.PaymentMethod,
cmd.IdempotencyKey.ToString() // Gateway idempotency key
);
payment.Status = gatewayResult.IsSuccess
? PaymentStatus.SUCCESS
: PaymentStatus.FAILED;
payment.GatewayTxnId = gatewayResult.TransactionId;
payment.CompletedAt = DateTime.UtcNow;
await _repo.UpdateAsync(payment);
return new PaymentResult
{
PaymentId = payment.PaymentId,
Status = payment.Status
};
}
}
12. Notification System
The notification system handles booking confirmations, payment receipts, show reminders, cancellation confirmations, and promotional messages. During peak booking periods, the system must send millions of notifications within minutes without impacting the booking flow. The design uses an event-driven architecture with Kafka as the message backbone and dedicated consumer services for each notification channel.
Notification Channels and SLAs
| Channel | Use Case | Latency SLA | Provider |
|---|---|---|---|
| Booking confirmation, tickets (PDF/QR), receipts | < 30 seconds | Amazon SES | |
| SMS | Booking confirmation, OTP, show reminders | < 10 seconds | MSG91 / Twilio |
| Push notification | Real-time alerts, show reminders, price drops | < 5 seconds | FCM + APNs |
| In-app | Booking status updates, wallet balance changes | < 1 second | WebSocket / SSE |
C#
public class NotificationConsumer : IConsumer<BookingConfirmedEvent>
{
private readonly IEmailService _emailService;
private readonly ISmsService _smsService;
private readonly IPushService _pushService;
public async Task ConsumeAsync(BookingConfirmedEvent evt)
{
var tasks = new List<Task>();
// Email with ticket PDF attachment
tasks.Add(Task.Run(async () =>
{
var ticketPdf = await GenerateTicketPdfAsync(evt.BookingId);
await _emailService.SendAsync(new EmailMessage
{
To = evt.UserEmail,
Subject = $"Booking Confirmed - {evt.MovieTitle}",
TemplateId = "booking_confirmed_v2",
Attachments = new[] { ticketPdf },
Variables = new Dictionary<string, string>
{
["movie_title"] = evt.MovieTitle,
["venue"] = evt.VenueName,
["show_time"] = evt.ShowTime.ToString("dd MMM, hh:mm tt"),
["seats"] = string.Join(", ", evt.Seats),
["amount"] = evt.FinalAmount.ToString("C"),
["booking_id"] = evt.BookingId.ToString()
}
});
}));
// SMS confirmation
tasks.Add(Task.Run(async () =>
{
await _smsService.SendAsync(new SmsMessage
{
To = evt.UserPhone,
TemplateId = "booking_confirmed",
Variables = new[] { evt.MovieTitle, evt.ShowTime.ToString("hh:mm tt"),
evt.BookingId.ToString()[..8] }
});
}));
// Push notification
tasks.Add(Task.Run(async () =>
{
await _pushService.SendAsync(new PushMessage
{
UserId = evt.UserId,
Title = "Booking Confirmed!",
Body = $"{evt.MovieTitle} at {evt.VenueName} — {evt.ShowTime:hh:mm tt}",
Data = new Dictionary<string, string>
{
["booking_id"] = evt.BookingId.ToString(),
["deep_link"] = $"bookmyshow://booking/{evt.BookingId}"
}
});
}));
// Send all notifications in parallel
await Task.WhenAll(tasks);
}
}
13. Search & Discovery
Search is a critical user experience driver. Users search by movie name, genre, language, city, date, and format. The search system must return results in under 200ms with support for fuzzy matching (handling typos), autocomplete suggestions, faceted filtering, and personalized results based on viewing history.
Elasticsearch Index Design
JSON
{
"mappings": {
"properties": {
"movie_id": { "type": "long" },
"title": {
"type": "text",
"analyzer": "custom_english",
"fields": {
"autocomplete": {
"type": "text",
"analyzer": "edge_ngram",
"search_analyzer": "standard"
},
"keyword": { "type": "keyword" }
}
},
"language": { "type": "keyword" },
"genre": { "type": "keyword" },
"city_id": { "type": "long" },
"release_date": { "type": "date" },
"show_dates": { "type": "date" },
"rating": { "type": "float" },
"formats": { "type": "keyword" },
"poster_url": { "type": "keyword", "index": false },
"synopsis": { "type": "text" },
"popularity_score": { "type": "float" },
"suggest": {
"type": "completion",
"analyzer": "simple"
}
}
}
}
Search Query Pipeline
C#
public class SearchService
{
private readonly IElasticClient _es;
public async Task<SearchResultDto> SearchMoviesAsync(SearchQuery query)
{
var searchRequest = _es.Search<MovieDocument>(s => s
.Index("movies")
.Query(q => q
.Bool(b => b
.Must(mu => mu
.MultiMatch(mm => mm
.Fields(f => f
.Field(p => p.Title, 2.0)
.Field(p => p.Title.Autocomplete(), 1.5)
.Field(p => p.Synopsis, 0.5))
.Query(query.Text)
.Type(TextQueryType.BestFields)
.Fuzziness(Fuzziness.Auto)
))
.Filter(
// City filter
query.CityId.HasValue
? bf => bf.Term(t => t.Field(p => p.CityId).Value(query.CityId.Value))
: null,
// Language filter
!string.IsNullOrEmpty(query.Language)
? bf => bf.Term(t => t.Field(p => t.Language).Value(query.Language))
: null,
// Genre filter
!string.IsNullOrEmpty(query.Genre)
? bf => bf.Term(t => t.Field(p => t.Genre).Value(query.Genre))
: null,
// Active shows filter
bf => bf.DateRange(dr => dr
.Field(p => p.ShowDates)
.GreaterThanOrEquals(DateTime.UtcNow.Date))
)
))
.Sort(ss => ss
.Descending(p => p.PopularityScore)
.Descending(p => p.Rating))
.Highlight(h => h
.Fields(f => f.Field(p => p.Title)))
.Size(query.PageSize ?? 20)
.From(query.Page ?? 0)
);
return MapToResult(searchRequest);
}
}
Search Feature Matrix
| Feature | Implementation | Latency Impact |
|---|---|---|
| Autocomplete | Edge n-gram analyzer + completion suggester | < 50ms |
| Fuzzy matching | Edit distance 1-2 via Lucene Fuzziness | +10-20ms |
| Faceted filtering | Aggregations on language, genre, format | +5ms |
| Geo-aware results | Function score with distance decay | +15ms |
| Personalization | Re-ranking based on user history embeddings | +30ms |
| Typo tolerance | Query suggestion + Did You Mean | +20ms |
14. Security & Fraud Prevention
BookMyShow faces unique security challenges: ticket scalping bots that buy seats in bulk for resale at inflated prices, credential stuffing attacks on user accounts, payment fraud through stolen cards, and DDoS attacks during blockbuster releases. The security architecture must protect against all of these while maintaining a frictionless experience for legitimate users.
Anti-Scalping Measures
| Measure | Implementation | Effectiveness |
|---|---|---|
| Rate limiting | User-level: 5 bookings/hour, IP-level: 100 req/min | Blocks automated bots |
| CAPTCHA | reCAPTCHA v3 risk scoring on booking initiation | 99.5% bot detection |
| Max tickets per transaction | Hard limit of 10 tickets per booking | Prevents bulk buying |
| Max tickets per user per show | Limit 2 bookings per user per show | Prevents multi-transaction hoarding |
| Device fingerprinting | Track device ID, browser fingerprint, behavioral patterns | Identifies coordinated attacks |
| ML-based fraud scoring | Real-time risk scoring using user behavior, device, velocity | Catches sophisticated scalpers |
Authentication & Authorization
C#
public class BookingAuthorizationFilter : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
var userId = GetUserIdFromJwt(context.HttpContext);
var idempotencyKey = context.HttpContext.Request
.Headers["Idempotency-Key"].FirstOrDefault();
// Verify idempotency key is present for write operations
if (string.IsNullOrEmpty(idempotencyKey))
{
context.Result = new BadRequestObjectResult(
new { error = "Idempotency-Key header is required" });
return;
}
// Rate limit check: max 5 bookings per hour per user
var bookingCount = await _rateLimiter
.GetBookingCountAsync(userId, TimeSpan.FromHours(1));
if (bookingCount >= 5)
{
context.Result = new StatusCodeActionResult(429);
return;
}
// Fraud score check
var fraudScore = await _fraudDetector
.EvaluateRequestAsync(userId, context.HttpContext);
if (fraudScore > 0.8)
{
await _notificationService.NotifySecurityTeamAsync(
userId, fraudScore, "High-risk booking attempt");
// Still allow but add extra verification
context.HttpContext.Items["requires_captcha"] = true;
}
await next();
}
}
15. Monitoring & Observability
The monitoring stack provides real-time visibility into system health, booking funnel conversion, payment success rates, and seat utilization. During flash sales, the operations team must see live metrics to identify and respond to issues within seconds.
Key Metrics Dashboard
| Metric | Alert Threshold | Dashboard Panel |
|---|---|---|
| Booking success rate | < 95% | Funnel visualization: browse → select → pay → confirm |
| Payment success rate | < 92% | Per-gateway success rate, failure reasons |
| Seat lock-to-booking ratio | < 60% | Locks acquired vs. confirmed bookings |
| Seat lock timeout rate | > 40% | Locks that expired without confirmation |
| API latency (p99) | > 500ms | Per-endpoint latency percentiles |
| Redis memory usage | > 80% | Per-node memory, eviction rate |
| PostgreSQL connections | > 80% pool | PgBouncer active/idle/waiting connections |
| Kafka consumer lag | > 10,000 msgs | Per-consumer-group lag and processing rate |
| Active users per show | > 5,000 | Real-time concurrent users per show |
| Error rate | > 1% | HTTP 5xx errors by service |
Distributed Tracing
C#
// OpenTelemetry instrumentation for booking flow
public class BookingService
{
private static readonly ActivitySource ActivitySource =
new("BookingService");
public async Task<BookingResult> CreateBookingAsync(CreateBookingCommand cmd)
{
using var activity = ActivitySource.StartActivity("CreateBooking");
activity?.SetTag("user_id", cmd.UserId);
activity?.SetTag("show_id", cmd.ShowId);
activity?.SetTag("seat_count", cmd.SeatIds.Length);
// Trace: Lock seats
using (var lockActivity = ActivitySource.StartActivity("LockSeats"))
{
var lockResult = await _seatLockService.LockSeatsAsync(
cmd.ShowId, cmd.SeatIds, cmd.UserId);
lockActivity?.SetTag("locks_acquired",
lockResult.Results.Count(r => r.Success));
}
// Trace: Calculate price
using (var priceActivity = ActivitySource.StartActivity("CalculatePrice"))
{
var price = await _pricingEngine.CalculateAsync(
cmd.ShowId, cmd.SeatIds);
priceActivity?.SetTag("total_amount", price);
}
// Trace: Initiate payment
using (var payActivity = ActivitySource.StartActivity("InitiatePayment"))
{
var payment = await _paymentService.InitiateAsync(cmd.BookingId);
payActivity?.SetTag("payment_id", payment.PaymentId);
}
activity?.SetTag("booking.status", "PENDING");
return result;
}
}
16. Multi-Region Design
BookMyShow operates across multiple cities in India and has expanded to the Middle East and Southeast Asia. The multi-region design must handle geo-distributed traffic while ensuring that seat inventory for a specific show is always managed from a single primary region to prevent cross-region double-booking.
Region Architecture
Multi-Region Strategy
| Data Type | Strategy | Consistency |
|---|---|---|
| Movie metadata | Multi-region read replicas | Eventual (acceptable) |
| Show listings | Multi-region read replicas | Eventual (minutes lag acceptable) |
| Seat inventory | Primary-region writes only; reads from regional Redis | Strong (single-writer) |
| Bookings | Routed to primary region | Strong (ACID) |
| Payments | Routed to primary region | Strong (ACID) |
| User sessions | Global Redis with CRDT | Eventual (session tolerance) |
17. Cost Estimation
| Component | Specification | Monthly Cost (Est.) |
|---|---|---|
| PostgreSQL (RDS Multi-AZ) | db.r6g.2xlarge x 3 + read replicas x 6 | $8,500 |
| Redis (ElastiCache) | r6g.xlarge cluster x 6 nodes | $4,200 |
| Elasticsearch | m6g.xlarge.search x 5 nodes | $3,100 |
| Cassandra (Keyspaces) | On-demand, ~50M reads + 10M writes/day | $2,800 |
| Application servers (ECS Fargate) | 50+ tasks, 4 vCPU / 8 GB each | $6,500 |
| Load balancers (ALB) | 3 ALBs with WAF | $1,200 |
| Kafka (MSK) | $3,800 | |
| S3 + CloudFront CDN | 2 TB storage, 50 TB transfer/month | $1,500 |
| Email (SES) | 10M emails/month | $1,000 |
| SMS (MSG91) | 5M SMS/month | $2,500 |
| Monitoring (Datadog) | Infrastructure + APM + Logs | $3,200 |
| Total estimated | ~$38,300/month |
18. Edge Cases
| Edge Case | Impact | Mitigation |
|---|---|---|
| Payment succeeds but confirmation webhook is lost | User charged but no ticket | Periodic reconciliation: query payment gateway for pending transactions, match with internal bookings, retry confirmation |
| User locks seats but payment gateway is down | Seats locked for 10 min, unavailable to others | Circuit breaker on payment gateway; redirect to backup gateway within 3 seconds; auto-release seats on gateway failure |
| Concurrent lock attempt for same seat | One user sees success, other sees failure | Redis NX flag ensures mutual exclusion; failing user immediately sees "seat no longer available" |
| Show cancelled after tickets sold | Users have paid for invalid tickets | Automated refund pipeline triggered by show cancellation event; notification to all affected users |
| Seat layout change mid-day | Inconsistent seat map for evening shows | Layout changes only take effect for shows created after the change; existing shows use cached layout snapshot |
| Flash sale causes 100x traffic spike | System overload, slow response | Virtual waiting queue (like Ticketmaster); queue position assigned, users notified when it is their turn |
| User books ticket then requests chargeback | Revenue loss + potential fraud | Flag user account; require manual review for chargebacks over ₹500; integrate with chargeback management system |
| Duplicate idempotency key from different requests | Second request returns cached response for wrong booking | Idempotency key scoped to user + endpoint; reject if key already used for different parameters |
19. Interview Q&A
Q1: How do you prevent double-booking of seats?
Double-booking prevention uses a two-layer approach. The first layer is a Redis-based distributed lock using the SET key value NX EX ttl command, which atomically checks if a seat is available and locks it. The second layer is an optimistic concurrency control on the PostgreSQL seat_inventory table using a version column — when the booking is confirmed, an UPDATE ... WHERE version = expected_version ensures no other transaction has modified the seat in between. If the version check fails, the booking is rejected. This two-layer approach provides both performance (Redis handles the hot path) and correctness (PostgreSQL provides the durable guarantee).
Q2: How do you handle flash sale traffic spikes?
Flash sales require three key mechanisms: (1) A virtual waiting queue that assigns users a queue position when they arrive, preventing all users from hitting the seat inventory simultaneously. Users see a countdown timer and are let into the booking flow in batches of 100. (2) Aggressive caching of movie details, show listings, and seat layouts in Redis and CDN to absorb 90%+ of read traffic without hitting the database. (3) Auto-scaling with scheduled scaling policies that pre-warm additional capacity 30 minutes before a known flash sale event. The combination of load leveling via the queue, read traffic absorption via caching, and capacity pre-warming via auto-scaling prevents system overload.
Q3: What happens if the payment succeeds but the booking confirmation fails?
This is handled by a reconciliation service that runs every 5 minutes. It queries the payment gateway API for transactions in the last 10 minutes that have a SUCCESS status, and cross-references them with internal booking records. If a payment is successful but the booking is still in PENDING status, the reconciliation service retries the booking confirmation. If the booking record does not exist at all (catastrophic failure), it creates the booking record directly from the payment data and triggers ticket generation. This ensures no user is ever charged without receiving their ticket. The reconciliation job also handles the reverse case — bookings marked as confirmed but payments that failed at the gateway — by cancelling the booking and releasing the seats.
Q4: How do you design the seat map for different theater configurations?
Each theater screen has a unique seat layout stored as a matrix in the seat_layouts table. The layout defines row labels (A, B, C...), seat numbers within each row, seat type (recliner, premium, standard), base price, and physical attributes (aisle positions, wheelchair-accessible seats, couple seats). The front-end renders the seat map based on this layout definition, including curved rows, missing seats (aisles), and different seat widths for recliners. The layout is stored as a JSON document in Redis for fast retrieval and is updated only when the theater physically reconfigures the screen — a rare event that does not require real-time consistency.
Q5: How do you handle seat lock timeouts during payment processing?
When a user locks seats, a 10-minute TTL is set on the Redis lock key. A background worker (LockExpirationWorker) runs every 10 seconds, checking for expired locks and restoring seats to AVAILABLE status. During payment processing, if the payment takes longer than expected (e.g., user is slow to enter UPI PIN), the system attempts to extend the lock once at the 8-minute mark. If the lock expires during payment processing, the payment gateway call is allowed to complete (to avoid double-charging), and the post-payment reconciliation service handles the case where the seat was already booked by another user — issuing a refund and notifying the original user.
Q6: How do you design the refund and cancellation flow?
The cancellation flow starts with a user-initiated request through the app or website. The system checks the cancellation policy (defined per show by the theater — typically 24 hours before showtime for full refund, 12 hours for 50% refund, less than 4 hours no refund). If eligible, the booking status is updated to CANCELLED, the seats are released back to AVAILABLE, and a refund is initiated to the original payment method. The refund is processed asynchronously through the payment gateway, with the user seeing "Refund Initiated" status immediately. The actual refund takes 3-5 business days for cards and instant to 24 hours for UPI. A notification is sent at each stage: cancellation confirmed, refund initiated, refund completed.
Q7: How do you handle concurrent users trying to book the last seat?
This is the classic "thundering last seat" problem. When only one seat remains for a popular show, hundreds of users may attempt to lock it simultaneously. The Redis NX-based lock ensures that only one user successfully acquires the lock. The other users immediately receive a "seat no longer available" response without any database query. The lock acquisition is O(1) in Redis, so even 10,000 concurrent attempts for the same seat complete within milliseconds. The front-end handles this gracefully by showing a real-time update that the seat was taken, prompting the user to select alternative seats.
Q8: How do you design the system to handle show cancellations and rescheduling?
When a theater cancels or reschedules a show, the Show Service publishes a ShowCancelled or ShowRescheduled event. The Booking Service consumes this event and: (1) identifies all affected bookings, (2) marks them as CANCELLED, (3) triggers full refunds to all affected users, (4) releases all seats back to the inventory, and (5) sends personalized notifications explaining the situation and next steps. For rescheduled shows, users are given the option to rebook for the new time with a priority lock window (e.g., 24 hours of exclusive access to book the rescheduled show). The entire process is automated but has a manual override for the operations team to handle edge cases.
Q9: How do you ensure idempotency across the booking flow?
Every mutating API call requires an Idempotency-Key header (UUID v4). The server stores the key with its response in a Redis hash with a 24-hour TTL. If a duplicate request arrives with the same key, the cached response is returned without reprocessing. This applies to: seat locking (prevents double-locking), payment initiation (prevents double-charging), and booking confirmation (prevents duplicate bookings). The idempotency key is scoped to the user ID and endpoint — the same key used for a seat lock cannot be reused for a payment call. This prevents replay attacks while allowing safe network retries.
Q10: How do you design the virtual waiting room for flash sales?
The virtual waiting room is implemented using a combination of Redis sorted sets and a token-bucket algorithm. When a flash sale begins, users entering the page are assigned a position in a Redis sorted set based on their arrival timestamp. The front-end polls a status endpoint every 5 seconds that returns: current position in queue, estimated wait time, and total users ahead. The system processes users in batches of 100 — when it is a user's turn, they receive a unique token that grants them access to the booking flow for 10 minutes. The queue is served FIFO to ensure fairness. Users who abandon their position (close the tab) are automatically removed after 2 minutes of inactivity. This prevents server overload while giving every user a fair chance at tickets.
Q11: How do you handle multiple payment methods and partial payments?
The Payment Service supports splitting a single booking payment across multiple payment methods (e.g., ₹200 from wallet + ₹550 from UPI). The total amount is split into line items, each processed as an independent payment transaction with its own idempotency key. All partial payments must succeed for the booking to be confirmed. If any partial payment fails, all successful partial payments are refunded atomically, and the booking is marked as FAILED. The wallet balance deduction is immediate, while UPI/card charges are asynchronous. The system tracks the partial payment state in the database and reconciles any inconsistencies within 30 minutes.
Q12: How would you scale the system to handle 10x current traffic?
Scaling to 10x requires changes across all layers: (1) Database: Increase shard count from 128 to 512 for bookings, add read replicas (from 3 to 10), implement query result caching for common patterns. (2) Redis: Scale from 6-node cluster to 16-node cluster, implement local L1 cache (IMemoryCache) to absorb 30% of Redis reads. (3) Application: Increase Fargate task count with auto-scaling policies, implement connection pooling more aggressively. (4) Async processing: Increase Kafka partition count for booking and payment topics, add more consumer instances. (5) Front-end: Implement client-side caching and optimistic UI updates to reduce round trips. (6) CDN: Extend cache coverage to include API responses for non-personalized endpoints (movie listings, show times).
Q13: How do you handle booking analytics and business intelligence?
Every booking event, payment event, and seat inventory change is published to Kafka topics. A stream processing job (using Apache Flink or Kafka Streams) computes real-time aggregations: bookings per show, revenue per venue, conversion rates, popular seat types, and payment method distribution. These aggregations are written to a ClickHouse OLAP database for business intelligence dashboards. Historical data (beyond 90 days) is archived to S3 and queried via Athena for ad-hoc analysis. The BI team uses tools like Metabase or Looker connected to ClickHouse for daily, weekly, and monthly reporting.
20. Full C# Implementation
Booking Service — Core Domain Model
C#
namespace BookMyShow.Domain.Entities;
public class Booking
{
public Guid BookingId { get; private set; }
public long UserId { get; private set; }
public long ShowId { get; private set; }
public BookingStatus Status { get; private set; }
public decimal TotalAmount { get; private set; }
public decimal ConvenienceFee { get; private set; }
public decimal DiscountAmount { get; private set; }
public decimal FinalAmount => TotalAmount + ConvenienceFee - DiscountAmount;
public List<BookingSeat> Seats { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? ConfirmedAt { get; private set; }
public DateTime ExpiresAt { get; private set; }
private Booking() { }
public static Booking Create(long userId, long showId, List<BookingSeat> seats,
decimal convenienceFee)
{
return new Booking
{
BookingId = Guid.NewGuid(),
UserId = userId,
ShowId = showId,
Status = BookingStatus.PENDING,
TotalAmount = seats.Sum(s => s.Price),
ConvenienceFee = convenienceFee,
Seats = seats,
CreatedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.AddMinutes(10)
};
}
public void Confirm()
{
if (Status != BookingStatus.PENDING)
throw new InvalidOperationException(
$"Cannot confirm booking in {Status} status");
if (DateTime.UtcNow > ExpiresAt)
throw new BookingExpiredException(BookingId);
Status = BookingStatus.CONFIRMED;
ConfirmedAt = DateTime.UtcNow;
}
public void Cancel(string reason)
{
if (Status == BookingStatus.CANCELLED || Status == BookingStatus.REFUNDED)
throw new InvalidOperationException("Booking already cancelled");
Status = BookingStatus.CANCELLED;
}
}
public enum BookingStatus
{
PENDING,
CONFIRMED,
CANCELLED,
REFUNDED,
EXPIRED
}
public class BookingSeat
{
public long SeatId { get; set; }
public string RowLabel { get; set; }
public int SeatNumber { get; set; }
public string SeatType { get; set; }
public decimal Price { get; set; }
}
Booking Service — Application Layer
C#
namespace BookMyShow.Application.Services;
public class BookingApplicationService
{
private readonly SeatLockService _seatLockService;
private readonly BookingRepository _bookingRepo;
private readonly PricingEngine _pricingEngine;
private readonly IEventBus _eventBus;
private readonly ILogger<BookingApplicationService> _logger;
public async Task<LockSeatsResponse> LockSeatsAsync(
LockSeatsCommand command)
{
_logger.LogInformation(
"User {UserId} attempting to lock {Count} seats for show {ShowId}",
command.UserId, command.SeatIds.Length, command.ShowId);
// Validate show is active and in the future
var show = await _bookingRepo.GetShowAsync(command.ShowId);
if (show == null || show.Status != ShowStatus.ACTIVE)
throw new ShowNotFoundException(command.ShowId);
if (show.ShowDate.Add(show.ShowTime) <= DateTime.UtcNow)
throw new ShowAlreadyStartedException(command.ShowId);
// Validate seat count (max 10 per booking)
if (command.SeatIds.Length > 10)
throw new ValidationException("Maximum 10 seats per booking");
// Check user hasn't exceeded booking limit for this show
var existingBookings = await _bookingRepo
.GetActiveBookingsForUserShowAsync(command.UserId, command.ShowId);
if (existingBookings.Count >= 2)
throw new BookingLimitExceededException(
"Maximum 2 bookings per user per show");
// Attempt to lock all seats atomically
var lockResult = await _seatLockService.LockSeatsAsync(
command.ShowId, command.SeatIds, command.UserId);
if (!lockResult.AllLocked)
{
// Release any seats that were successfully locked
var lockedSeats = lockResult.Results
.Where(r => r.Success)
.Select(r => r.SeatId)
.ToArray();
if (lockedSeats.Any())
{
await _seatLockService.ReleaseSeatsAsync(
command.ShowId, lockedSeats, lockResult.LockId, command.UserId);
}
var failedSeats = lockResult.Results
.Where(r => !r.Success)
.Select(r => r.SeatId)
.ToList();
throw new SeatUnavailableException(failedSeats);
}
// Calculate pricing
var seats = command.SeatIds.Select(seatId =>
{
var (row, num) = ParseSeatId(seatId);
return new BookingSeat { SeatId = 0, RowLabel = row, SeatNumber = num };
}).ToList();
var pricing = await _pricingEngine.CalculateAsync(
command.ShowId, command.SeatIds);
// Create pending booking
var booking = Booking.Create(
command.UserId,
command.ShowId,
seats,
pricing.ConvenienceFee);
booking.TotalAmount = pricing.TotalSeatPrice;
await _bookingRepo.SaveAsync(booking);
return new LockSeatsResponse
{
LockId = lockResult.LockId,
BookingId = booking.BookingId,
ExpiresAt = lockResult.Results.First().ExpiresAt,
TotalAmount = pricing.TotalSeatPrice,
ConvenienceFee = pricing.ConvenienceFee,
FinalAmount = booking.FinalAmount,
Seats = seats
};
}
public async Task<ConfirmBookingResponse> ConfirmBookingAsync(
ConfirmBookingCommand command)
{
var booking = await _bookingRepo.GetByIdAsync(command.BookingId);
if (booking == null)
throw new BookingNotFoundException(command.BookingId);
if (booking.UserId != command.UserId)
throw new UnauthorizedException("Not your booking");
// Verify payment is successful
var payment = await _bookingRepo.GetPaymentAsync(command.PaymentId);
if (payment == null || payment.Status != PaymentStatus.SUCCESS)
throw new PaymentNotCompletedException(command.PaymentId);
if (payment.BookingId != command.BookingId)
throw new ValidationException("Payment does not match booking");
// Confirm booking (with idempotency)
if (booking.Status == BookingStatus.CONFIRMED)
{
return new ConfirmBookingResponse
{
BookingId = booking.BookingId,
Status = "CONFIRMED",
IsDuplicate = true
};
}
booking.Confirm();
await _bookingRepo.UpdateAsync(booking);
// Generate ticket
var ticket = await GenerateTicketAsync(booking);
// Publish booking confirmed event
await _eventBus.PublishAsync(new BookingConfirmedEvent
{
BookingId = booking.BookingId,
UserId = booking.UserId,
ShowId = booking.ShowId,
MovieTitle = booking.Show.Movie.Title,
VenueName = booking.Show.Screen.Venue.Name,
ShowTime = booking.Show.ShowTime,
Seats = booking.Seats.Select(s => $"{s.RowLabel}{s.SeatNumber}").ToList(),
FinalAmount = booking.FinalAmount,
UserEmail = booking.User.Email,
UserPhone = booking.User.Phone
});
return new ConfirmBookingResponse
{
BookingId = booking.BookingId,
Status = "CONFIRMED",
TicketUrl = ticket.DownloadUrl,
QrCodeBase64 = ticket.QrCodeImage
};
}
}
Seat Lock Service — Atomic Lua Script
C#
namespace BookMyShow.Infrastructure.Services;
public class SeatLockService
{
private readonly IDatabase _redis;
private readonly TimeSpan _lockTTL = TimeSpan.FromMinutes(10);
private const string AtomicLockScript = @"
local seatKey = KEYS[1]
local lockKey = KEYS[2]
local seatId = ARGV[1]
local userId = ARGV[2]
local ttl = ARGV[3]
-- Check current seat status
local status = redis.call('HGET', seatKey, seatId)
if status == 'BOOKED' or status == 'LOCKED' then
return {'FAILED', status}
end
-- Try to acquire lock (atomic)
local acquired = redis.call('SET', lockKey, userId, 'NX', 'EX', ttl)
if not acquired then
return {'FAILED', 'LOCKED_BY_OTHER'}
end
-- Update seat status
redis.call('HSET', seatKey, seatId, 'LOCKED')
redis.call('HINCRBY', seatKey, 'available_count', -1)
redis.call('HINCRBY', seatKey, 'locked_count', 1)
return {'SUCCESS', lockKey}
";
public async Task<SeatLockResult> LockSeatAtomicAsync(
long showId, string seatId, Guid userId)
{
var seatKey = $"show:{showId}:seats";
var lockKey = $"lock:show:{showId}:seat:{seatId}";
var result = await _redis.ScriptEvaluateAsync(
AtomicLockScript,
new RedisKey[] { seatKey, lockKey },
new RedisValue[] { seatId, userId.ToString(), (int)_lockTTL.TotalSeconds });
var resultArray = (RedisValue[])result;
var success = resultArray[0].ToString() == "SUCCESS";
return new SeatLockResult
{
SeatId = seatId,
Success = success,
LockId = success ? Guid.NewGuid() : Guid.Empty,
ExpiresAt = success ? DateTimeOffset.UtcNow.Add(_lockTTL) : DateTimeOffset.MinValue,
Reason = success ? null : $"Seat unavailable: {resultArray[1]}"
};
}
}
Pricing Engine — Full Implementation
C#
namespace BookMyShow.Domain.Services;
public class PricingEngine
{
private readonly IPricingRuleRepository _ruleRepo;
private readonly IDemandTracker _demandTracker;
private readonly IHolidayCalendar _holidayCalendar;
public async Task<PricingResult> CalculateAsync(
long showId, string[] seatIds)
{
var show = await _ruleRepo.GetShowPricingContextAsync(showId);
var rules = await _ruleRepo.GetPricingRulesAsync(show.Venue.CityId);
var demand = await _demandTracker.GetCurrentDemandAsync(showId);
var seatPrices = new List<SeatPriceResult>();
foreach (var seatId in seatIds)
{
var seatLayout = await _ruleRepo.GetSeatLayoutAsync(
show.ScreenId, seatId);
decimal basePrice = seatLayout.BasePrice;
// Apply multipliers
decimal timeMultiplier = GetTimeMultiplier(show.ShowTime);
decimal dayMultiplier = GetDayMultiplier(show.ShowDate);
decimal holidayMultiplier = _holidayCalendar.IsHoliday(show.ShowDate)
? rules.HolidaySurcharge : 1.0m;
decimal demandMultiplier = GetDemandMultiplier(demand);
decimal formatMultiplier = GetFormatMultiplier(show.Format);
decimal finalPrice = Math.Round(
basePrice * timeMultiplier * dayMultiplier
* holidayMultiplier * demandMultiplier * formatMultiplier);
seatPrices.Add(new SeatPriceResult
{
SeatId = seatId,
RowLabel = seatLayout.RowLabel,
SeatNumber = seatLayout.SeatNumber,
SeatType = seatLayout.SeatType,
BasePrice = basePrice,
FinalPrice = finalPrice
});
}
decimal totalSeatPrice = seatPrices.Sum(s => s.FinalPrice);
decimal convenienceFee = Math.Round(totalSeatPrice * 0.04m, 0);
convenienceFee = Math.Max(convenienceFee, 10); // Minimum ₹10
return new PricingResult
{
SeatPrices = seatPrices,
TotalSeatPrice = totalSeatPrice,
ConvenienceFee = convenienceFee,
Currency = "INR"
};
}
private decimal GetTimeMultiplier(TimeSpan showTime)
{
return showTime.TotalHours switch
{
>= 6 and < 12 => 0.85m,
>= 12 and < 17 => 1.00m,
>= 17 and < 21 => 1.20m,
>= 21 => 1.10m,
_ => 1.00m
};
}
private decimal GetDayMultiplier(DateTime showDate)
{
return showDate.DayOfWeek switch
{
DayOfWeek.Monday or DayOfWeek.Tuesday or DayOfWeek.Wednesday => 0.85m,
DayOfWeek.Thursday => 0.95m,
DayOfWeek.Friday => 1.10m,
DayOfWeek.Saturday => 1.20m,
DayOfWeek.Sunday => 1.10m,
_ => 1.00m
};
}
private decimal GetDemandMultiplier(DemandLevel demand)
{
return demand switch
{
DemandLevel.Low => 0.90m,
DemandLevel.Normal => 1.00m,
DemandLevel.High => 1.15m,
DemandLevel.VeryHigh => 1.30m,
DemandLevel.Surge => 1.50m,
_ => 1.00m
};
}
private decimal GetFormatMultiplier(string format)
{
return format switch
{
"IMAX" => 1.80m,
"4DX" => 2.00m,
"DOLBY_ATMOS" => 1.60m,
"3D" => 1.30m,
"IMAX_3D" => 2.20m,
_ => 1.00m
};
}
}
21. Conclusion
Designing a ticket booking platform like BookMyShow is one of the most comprehensive system design exercises because it touches nearly every distributed systems concept: real-time inventory management, distributed locking, payment orchestration, event-driven architecture, caching strategies, search systems, and fraud prevention. The key architectural insight is that the seat inventory is the most critical shared resource, and every design decision flows from protecting its consistency. Redis-first seat status with Lua-based atomic locking provides the performance needed for flash sales. PostgreSQL with optimistic concurrency control provides the durability needed for financial transactions. Kafka-based event streaming connects the booking, payment, and notification services without tight coupling.
Key Numbers to Remember
| Metric | Value |
|---|---|
| Monthly active users | 50 million |
| Peak concurrent users (flash sale) | 500,000+ |
| Peak booking TPS | 15,000 |
| Seat lock TTL | 10 minutes |
| Lock-to-booking conversion target | > 70% |
| Payment success rate target | > 95% |
| Booking confirmation latency (p99) | < 500ms |
| Search results latency (p99) | < 200ms |
| Seat layout Redis read (p99) | < 5ms |
| Availability target | 99.99% (4.32 min/month) |
Production Checklist
- Implement Redis-based atomic seat locking with Lua scripts and 10-minute TTL
- Use PostgreSQL with optimistic concurrency control (version column) for booking confirmation
- Integrate at least 2 payment gateways with automatic failover
- Implement idempotency keys for all mutating API endpoints
- Deploy a reconciliation job that runs every 5 minutes to catch lost webhooks
- Set up a virtual waiting room for flash sales with queue-based load leveling
- Cache movie metadata and show listings at 3 levels: L1 in-memory, L2 Redis, L3 PostgreSQL
- Use Kafka for all async communication: booking events, payment events, notifications
- Implement anti-scalping measures: rate limiting, CAPTCHA, device fingerprinting, ML fraud scoring
- Monitor the booking funnel conversion rate as the top business metric
- Use OpenTelemetry for distributed tracing across all services
- Implement circuit breakers on payment gateway integrations with automatic failover
- Set up multi-region read replicas with single-writer pattern for seat inventory
- Archive booking history to Cassandra for 7-year retention
- Test the entire booking flow under 10x peak load before each blockbuster release
Common Interview Mistakes to Avoid
- Using a simple database decrement for seat inventory — it does not handle concurrent access or partial failures; you need distributed locking
- Ignoring the payment-webhook-lost edge case — always design for reconciliation between your system and the payment gateway
- Making booking confirmation synchronous with payment processing — separate the concerns: lock → pay → confirm via Saga pattern
- Not discussing idempotency — network retries in distributed systems are guaranteed; without idempotency, you will have double bookings and double charges
- Forgetting about seat lock timeouts — if a user locks seats but never pays, other users cannot book those seats until the lock expires; the 10-minute TTL and background cleanup worker are essential
- Not considering flash sale scenarios — a system that works at 500 TPS will crash at 15,000 TPS; you must discuss load leveling, auto-scaling, and caching under extreme load
- Ignoring the search and discovery layer — in a booking platform, the search experience is as important as the booking flow; discuss Elasticsearch integration
- Not separating the read path from the write path — reads (browsing, searching) should be served from cached/read-replica paths while writes (booking, payment) go through the primary database
Key Takeaways
A ticket booking system is fundamentally a real-time inventory management system with financial transaction requirements. The architecture decisions you make — from the Redis-first seat locking strategy to the Saga-based payment flow to the event-driven notification pipeline — all flow from two core requirements: seat uniqueness (never double-book) and payment reliability (never charge without confirming). The seat inventory service is the most critical component and must be designed for extreme concurrency: Redis Lua scripts provide atomic check-and-lock, the 10-minute TTL prevents indefinite seat holding, and the background cleanup worker ensures consistency after crashes.
Whether you are designing a ticket booking platform for movies, concerts, sports events, or conferences, the fundamental principles remain the same: protect the seat inventory with distributed locking, orchestrate payments with idempotent Saga patterns, cache aggressively for read-heavy paths, use event-driven architecture for async operations, and always design for the worst-case flash sale scenario. The companies that have mastered these principles — BookMyShow, Ticketmaster, StubHub, and Eventbrite — have built systems that handle millions of concurrent users competing for limited seats, processing billions of dollars in transactions while maintaining sub-second response times and 99.99% availability. Mastering this design gives you a comprehensive understanding of distributed systems that applies to many other domains, from airline reservation systems to ride-sharing platforms to real-time bidding systems.