Senior 3 min · June 25, 2026

Flight Booking System Design: Avoid Double-Booking and Payment Race Conditions

Design a flight booking system that handles concurrency, idempotency, and failures.

N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Everything here is grounded in real deployments.

Follow
Production
production tested
June 25, 2026
last updated
1,663
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Use optimistic locking with version numbers on seat inventory rows, implement a two-phase commit via saga pattern with compensating transactions, and enforce idempotency keys on payment requests to prevent duplicate charges.

✦ Definition~90s read
What is Design a Flight Booking System?

A flight booking system is a distributed transaction orchestrator that manages seat inventory, payment processing, and booking confirmations across multiple services while ensuring no double-booking occurs and payments are idempotent.

Think of it like a concert ticket booth with one clerk.
Plain-English First

Think of it like a concert ticket booth with one clerk. When you pick a seat, the clerk puts a 'reserved' sign on it. If you walk away without paying, the sign comes off. If you pay, the sign becomes 'sold'. Now imagine 1000 clerks — you need a system so two clerks don't sell the same seat, and if a payment fails, the seat goes back to available. That's the booking system.

Every flight booking system has the same nightmare: two users book the last seat, both get charged, and you have to refund one while explaining to an angry customer. I've seen this bring down a payments service when the thread pool was exhausted at 3am because a booking lock wasn't released. The problem isn't just concurrency — it's distributed state across inventory, payment, and booking services. This article walks you through a production-tested design that prevents double-booking, handles payment failures gracefully, and scales to thousands of bookings per second. By the end, you'll be able to design a booking system that survives a flash sale without data corruption.

Why Naive Locking Fails Under Load

Most tutorials show you SELECT FOR UPDATE on the seat row and call it done. That works until you have 500 concurrent requests for the same flight. The lock becomes a bottleneck — every request queues on that row. Worse, if your application holds the transaction open while calling a payment gateway (which can take 2-3 seconds), you'll exhaust your database connection pool. I've seen ERROR: 53300: too many connections in production because of this. The fix: use optimistic locking with a version column. Read the seat, check version, update where version = old_version. If zero rows affected, retry. This avoids long-held locks and scales linearly.

SeatInventoryOptimisticLocking.systemdesignSYSTEMDESIGN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — System Design tutorial

// Optimistic locking for seat inventory
// Assume seats table: id, flight_id, seat_number, version, status

public boolean bookSeat(long seatId, long expectedVersion) {
    String sql = "UPDATE seats SET status = 'booked', version = version + 1 " +
                 "WHERE id = ? AND version = ? AND status = 'available'";
    int updated = jdbcTemplate.update(sql, seatId, expectedVersion);
    if (updated == 0) {
        // Either seat already booked or version mismatch
        // Retry logic: re-read seat and retry up to 3 times
        return false;
    }
    return true;
}
Output
Returns true if seat was booked, false if conflict. No locks held across network calls.
Production Trap: Transaction Timeouts
If you use pessimistic locking, set a statement timeout (e.g., SET statement_timeout = '5s') to prevent a slow payment gateway from holding locks forever. Otherwise, you'll hit ERROR: 57014: canceling statement due to statement timeout — but only after the lock has already caused connection pool exhaustion.
Locking Strategy Decision Tree
IfWrite contention < 10 req/s per seat
UsePessimistic locking (SELECT FOR UPDATE) — simpler, safe
IfWrite contention > 10 req/s per seat
UseOptimistic locking with version column — no lock contention
IfCross-service transaction needed
UseSaga pattern with compensating transactions
Flight Booking System: Avoiding Double-Booking & Payment Race Conditions THECODEFORGE.IO Flight Booking System: Avoiding Double-Booking & Payment Race Conditions Design patterns for reliable booking and payment processing Idempotency Key Unique request identifier prevents duplicate processing Transactional Outbox Events stored in DB before publishing to message broker Saga Pattern Compensating transactions for partial failure recovery Payment Gateway Timeout Handling Retry with idempotency, circuit breaker, fallback Booking Confirmed Consistent state after successful payment and seat lock ⚠ Naive locking fails under high concurrency Use optimistic locking or distributed locks with idempotency THECODEFORGE.IO
thecodeforge.io
Flight Booking System: Avoiding Double-Booking & Payment Race Conditions
Design Flight Booking

Idempotency Keys: Your Shield Against Duplicate Payments

The classic rookie mistake: when a payment request times out, the client retries. Without an idempotency key, the payment gateway charges the card twice. I've seen this result in $40k in duplicate charges during a Black Friday sale. The fix: generate a unique idempotency key (UUID) on the client for each booking attempt. Send it with the payment request. The payment gateway stores the key and returns the same response for duplicate requests. On your side, store the key in the booking record. If you receive a retry, check the key — if the booking already exists, return the existing confirmation without processing payment again.

IdempotencyKeyCheck.systemdesignSYSTEMDESIGN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// io.thecodeforge — System Design tutorial

// Idempotency check before processing payment
public BookingResult processBooking(BookingRequest request) {
    // Check if idempotency key already processed
    Booking existing = bookingRepository.findByIdempotencyKey(request.getIdempotencyKey());
    if (existing != null) {
        return new BookingResult(existing, true); // idempotent return
    }
    // Proceed with payment and booking creation
    PaymentResult payment = paymentGateway.charge(request.getAmount(), request.getIdempotencyKey());
    if (!payment.isSuccess()) {
        throw new PaymentFailedException(payment.getError());
    }
    Booking booking = bookingRepository.save(request.toBooking());
    return new BookingResult(booking, false);
}
Output
If same idempotency key, returns existing booking without charging again. Otherwise, processes payment and creates booking.
Senior Shortcut: Idempotency Key Storage
Store idempotency keys in a separate table with a unique constraint. Use a TTL (e.g., 24 hours) to auto-clean old keys. This prevents the table from growing unbounded. In PostgreSQL, use ON CONFLICT DO NOTHING for fast duplicate detection.
Naive vs Idempotent PaymentTHECODEFORGE.IONaive vs Idempotent PaymentHow idempotency keys prevent duplicate chargesWithout KeyClient times out and retriesGateway charges twiceNo way to deduplicateCustomer disputes $40kWith Idempotency KeySend UUID with requestGateway detects duplicate keyReturns original responseSingle charge guaranteedAlways generate a unique key per payment attempt, even on retryTHECODEFORGE.IO
thecodeforge.io
Naive vs Idempotent Payment
Design Flight Booking

Saga Pattern: Surviving Partial Failures

A booking involves multiple steps: lock seat, charge payment, confirm booking, send email. If payment succeeds but email fails, you can't roll back the payment. That's where the saga pattern comes in. Each step has a compensating action: if email fails, you don't roll back the payment — you just retry the email. But if payment fails, you need to release the seat lock. Implement a saga orchestrator that tracks each step's state. If a step fails irrecoverably, execute compensating transactions for all completed steps. Use a transactional outbox to ensure the saga state is persisted atomically with the step execution.

SagaOrchestrator.systemdesignSYSTEMDESIGN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — System Design tutorial

// Saga orchestrator for booking
public class BookingSaga {
    private final SagaState state;

    public void execute(BookingRequest request) {
        try {
            state.start();
            lockSeat(request); // step 1
            state.stepCompleted("lockSeat");
            chargePayment(request); // step 2
            state.stepCompleted("chargePayment");
            confirmBooking(request); // step 3
            state.stepCompleted("confirmBooking");
            sendConfirmation(request); // step 4
            state.complete();
        } catch (Exception e) {
            compensate(state.getCompletedSteps(), request);
            state.fail();
        }
    }

    private void compensate(List<String> steps, BookingRequest request) {
        if (steps.contains("confirmBooking")) {
            cancelBooking(request); // compensating action
        }
        if (steps.contains("chargePayment")) {
            refundPayment(request); // compensating action
        }
        if (steps.contains("lockSeat")) {
            releaseSeat(request); // compensating action
        }
    }
}
Output
Executes steps in order. On failure, runs compensating actions in reverse order. State persisted in database for recovery.
Never Do This: Ignoring Compensating Action Failures
If a compensating action fails (e.g., refund API is down), you must retry with exponential backoff. Log the failure and alert. Otherwise, you'll have a booking with a charged payment but no seat — and no way to fix it automatically.
Booking Saga FlowTHECODEFORGE.IOBooking Saga FlowEach step has a compensating action on failureLock SeatReserve seat in DBCharge PaymentProcess via gatewayConfirm BookingMark as confirmedSend EmailNotify customerCompensateRollback on any failure⚠ Without compensating actions, partial failures leave inconsistent stateTHECODEFORGE.IO
thecodeforge.io
Booking Saga Flow
Design Flight Booking

Transactional Outbox: Reliable Event Publishing

When you update seat inventory and create a booking in the same database transaction, you often need to publish an event (e.g., 'booking confirmed') to a message queue. If the queue publish fails after the DB commit, you lose the event. The transactional outbox pattern solves this: instead of publishing directly, write the event to an 'outbox' table in the same DB transaction. A separate process (polling or CDC) reads from the outbox and publishes to the queue. This guarantees at-least-once delivery. I've used this pattern to avoid missing booking confirmations during a Kafka broker outage.

TransactionalOutbox.systemdesignSYSTEMDESIGN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// io.thecodeforge — System Design tutorial

// Transactional outbox: write event in same transaction as booking
@Transactional
public void createBooking(BookingRequest request) {
    // 1. Update seat inventory
    seatRepository.updateStatus(request.getSeatId(), "booked");
    // 2. Create booking record
    Booking booking = bookingRepository.save(request.toBooking());
    // 3. Write outbox event
    OutboxEvent event = new OutboxEvent("BookingConfirmed", booking.getId());
    outboxRepository.save(event);
    // Transaction commits — both booking and event persisted atomically
}

// Separate process: poll outbox and publish
@Scheduled(fixedDelay = 1000)
public void publishOutboxEvents() {
    List<OutboxEvent> events = outboxRepository.findTop100ByPublishedFalse();
    for (OutboxEvent event : events) {
        try {
            messageQueue.publish(event.getType(), event.getPayload());
            event.setPublished(true);
            outboxRepository.save(event);
        } catch (Exception e) {
            log.error("Failed to publish event: {}", event.getId(), e);
            // will retry on next poll
        }
    }
}
Output
Booking and outbox event saved atomically. Outbox publisher sends events to queue with at-least-once guarantee.
Interview Gold: CDC vs Polling
Polling the outbox table adds load and latency. For high throughput, use Change Data Capture (CDC) with Debezium to stream outbox events from the database WAL. This reduces poll interval to milliseconds and avoids table locks.

Handling Payment Gateway Timeouts Gracefully

Payment gateways can timeout after 30 seconds. If you hold a database lock during that time, you'll block other bookings. The solution: use a two-phase approach. First, reserve the seat with a short TTL (e.g., 10 minutes) without holding a lock. Then, process payment asynchronously. If payment succeeds, confirm the booking. If it fails or times out, the TTL expires and the seat becomes available again. This is how airlines actually work — they 'hold' a seat for 10 minutes while you enter payment details. Implement this with a reserved_until timestamp column on the seat.

SeatReservationWithTTL.systemdesignSYSTEMDESIGN
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// io.thecodeforge — System Design tutorial

// Reserve seat with TTL
public boolean reserveSeat(long seatId, int ttlMinutes) {
    String sql = "UPDATE seats SET status = 'reserved', reserved_until = NOW() + ?::interval " +
                 "WHERE id = ? AND status = 'available'";
    int updated = jdbcTemplate.update(sql, ttlMinutes + " minutes", seatId);
    return updated > 0;
}

// Confirm booking (called after payment)
public boolean confirmBooking(long seatId, String reservationToken) {
    String sql = "UPDATE seats SET status = 'booked', reserved_until = NULL " +
                 "WHERE id = ? AND status = 'reserved' AND reserved_until > NOW()";
    int updated = jdbcTemplate.update(sql, seatId);
    return updated > 0;
}

// Background job to release expired reservations
@Scheduled(fixedDelay = 60000)
public void releaseExpiredReservations() {
    String sql = "UPDATE seats SET status = 'available', reserved_until = NULL " +
                 "WHERE status = 'reserved' AND reserved_until < NOW()";
    jdbcTemplate.update(sql);
}
Output
Reserves seat for TTL minutes. Confirmation only works if within TTL. Expired reservations auto-released every minute.
Production Trap: Reservation Token Theft
A malicious user could guess seat IDs and confirm someone else's reservation. Always tie the reservation to a user session or token. Store the user_id in the reservation row and verify it on confirmation.

When Not to Use This Design

This design is overkill for a small airline with 10 flights a day. If your scale is low, a simple SELECT FOR UPDATE with a single database and synchronous payment processing works fine. Don't implement sagas, outboxes, and TTLs unless you have concurrency > 10 req/s per seat or you need to survive payment gateway outages. For a prototype, use a monolith with a single transaction. You can always split later. Also, if your payment gateway doesn't support idempotency keys, you can't use the idempotency pattern — fall back to manual reconciliation.

Senior Shortcut: Start Simple, Evolve
I've seen teams over-engineer from day one and burn out. Start with pessimistic locking and synchronous payment. Add optimistic locking when you see lock contention. Add sagas when you need to split services. Add outbox when you lose events. Measure before you optimize.
● Production incidentPOST-MORTEMseverity: high

The Double-Booking That Cost $50k in Refunds

Symptom
During a flash sale, 12 customers received confirmation emails for the same seat on a popular route. Support was flooded.
Assumption
The team assumed the database transaction was atomic — seat update and booking creation in one transaction.
Root cause
The seat inventory update used SELECT FOR UPDATE without a proper index, causing a table lock that serialized all writes. But the booking service had a timeout of 5s, and when the lock wait exceeded that, the booking was created without the seat update (because the transaction was split across two services via HTTP).
Fix
Moved seat inventory and booking creation into a single service with a local transaction. Added a version column on seats table and used optimistic locking with retry. Set a 30s timeout on the inventory lock.
Key lesson
  • Never split a seat deduction and booking confirmation across separate services without a saga or two-phase commit.
  • One transaction, one service.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Duplicate booking confirmations for same seat
Fix
1. Check seat inventory table for duplicate rows. 2. Verify optimistic locking version column is incremented. 3. Check application logs for retry logic without idempotency. 4. Add unique constraint on (flight_id, seat_number).
Symptom · 02
Payment charged but booking not created
Fix
1. Check payment gateway logs for successful charge. 2. Look for idempotency key in booking table. 3. If missing, manually create booking from payment data. 4. Implement transactional outbox to ensure booking creation is atomic with payment confirmation.
Symptom · 03
Seat remains reserved after user abandons booking
Fix
1. Check reserved_until column in seats table. 2. Verify background job is running and releasing expired reservations. 3. Check job logs for errors. 4. Manually release stuck reservations with SQL: UPDATE seats SET status='available', reserved_until=NULL WHERE status='reserved' AND reserved_until < NOW();
★ Flight Booking System Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Double-booking: two users got same seat
Immediate action
Check seat inventory for duplicate status='booked' on same seat
Commands
SELECT seat_id, COUNT(*) FROM seats WHERE status='booked' GROUP BY seat_id HAVING COUNT(*) > 1;
SELECT * FROM bookings WHERE seat_id = <duplicate_seat_id>;
Fix now
Add unique constraint on (flight_id, seat_number) and implement optimistic locking with version column.
Payment charged but no booking record+
Immediate action
Check payment gateway for successful transactions without corresponding booking
Commands
SELECT * FROM payments WHERE idempotency_key NOT IN (SELECT idempotency_key FROM bookings);
SELECT * FROM outbox_events WHERE type='BookingConfirmed' AND published=false;
Fix now
Manually create booking from payment data, then implement transactional outbox to prevent recurrence.
Seat stuck in 'reserved' state+
Immediate action
Check reserved_until timestamps for expired reservations
Commands
SELECT * FROM seats WHERE status='reserved' AND reserved_until < NOW();
SELECT * FROM jobs WHERE job_type='release_expired_reservations' AND last_run < NOW() - INTERVAL '2 minutes';
Fix now
Run manual release: UPDATE seats SET status='available', reserved_until=NULL WHERE status='reserved' AND reserved_until < NOW();
High database connection usage+
Immediate action
Check for long-running transactions holding locks
Commands
SELECT pid, state, query, age(now(), query_start) FROM pg_stat_activity WHERE state='active' AND query LIKE '%FOR UPDATE%';
SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE state='active' AND age(now(), query_start) > INTERVAL '30 seconds';
Fix now
Implement optimistic locking to avoid long-held locks, and set statement_timeout on transactions.
Feature / AspectOptimistic LockingPessimistic Locking
Lock durationNone (version check at update)Held until transaction end
ConcurrencyHigh (no lock contention)Low (serialized on row)
Retry neededYes (on version conflict)No (waits for lock)
Deadlock riskNonePossible (use ORDER BY)
Best forHigh contention, short transactionsLow contention, long transactions

Key takeaways

1
Optimistic locking with version columns beats pessimistic locking under high concurrency
no lock contention, no connection pool exhaustion.
2
Idempotency keys on payment requests are non-negotiable
they prevent duplicate charges from retries.
3
The saga pattern with compensating transactions is the only reliable way to handle distributed transactions across services.
4
Transactional outbox guarantees event delivery even when the message broker is down
write events in the same DB transaction as the business data.
5
Seat reservations with TTL avoid holding locks during payment processing
mimic real airline behavior.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does optimistic locking handle concurrent seat bookings under high l...
Q02SENIOR
When would you choose a saga pattern over a two-phase commit (2PC) for a...
Q03SENIOR
What happens when a payment gateway returns a success response but the n...
Q04JUNIOR
Explain how you would design a seat inventory system that supports holds...
Q05SENIOR
You notice that during a flash sale, the booking service's database conn...
Q06SENIOR
Design a system that allows users to book multi-city itineraries (e.g., ...
Q01 of 06SENIOR

How does optimistic locking handle concurrent seat bookings under high load? What happens when two users read the same version and both try to update?

ANSWER
Both users read version=1. First user's UPDATE succeeds (version becomes 2). Second user's UPDATE affects 0 rows because version is no longer 1. The second user must retry: re-read the seat (now version=2) and attempt update again. This avoids locks but requires retry logic with exponential backoff.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
How do I prevent double-booking in a flight booking system?
02
What's the difference between optimistic and pessimistic locking for seat inventory?
03
How do I handle payment failures in a booking system?
04
What happens if the payment gateway is down during booking?
N
Naren Founder & Principal Engineer

20+ years shipping large-scale distributed systems. Everything here is grounded in real deployments.

Follow
Verified
production tested
June 25, 2026
last updated
1,663
articles · all by Naren
🔥

That's Real World. Mark it forged?

3 min read · try the examples if you haven't

Previous
Design a Distributed Locking Service
40 / 40 · Real World