Designing an airline booking system: seat hold, payments, overbooking prevention
1. Overview
Selling airline tickets sounds simple: customers pick seats, pay, get a ticket. The hard part is thousands of people competing for a limited number of seats. Payments go through third parties and can be slow or return results multiple times.
In this article, you’ll design a ticket booking system from requirements to code. The system consists of time-bound seat hold, idempotent payments, preventing duplicate seat sales, and handling sudden traffic spikes.
After reading, you’ll have a design framework to apply to similar problems like concert tickets, hotel bookings, or flash sales.
2. Problem Requirements
2.1. Functional Requirements
- Search for flights and view available seat maps.
- Hold one or multiple seats for 10 minutes.
- Payment: if successful, issue ticket; if failed or expired, release seats.
- Cancel tickets and refund per policy.
2.2. Scale and Constraints
- About 5,000 flights/day, 180 seats per flight.
- Read volume (search, view seats) is about 100x write volume.
- When opening free ticket sales, there can be 50,000 requests/second hitting a few dozen flights.
- Hard constraints: a seat can never be sold to two people. The number of tickets sold per class cannot exceed the airline’s limit.
Note: Airlines intentionally overbook at a ratio, e.g., selling 183 tickets for 180 economy seats. The reason is that passengers always no-show. What we’re preventing is unintended overbooking due to race conditions. The intentional overbooking level must be a controlled parameter.
These two constraints determine the architecture. Seat inventory data needs strong consistency. The search part accepts eventual consistency and is heavily cached.
3. Data Model
We use PostgreSQL as the source of truth for inventory:
CREATE TABLE flight (
id BIGINT PRIMARY KEY,
code VARCHAR(10) NOT NULL,
departure_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE fare_inventory (
flight_id BIGINT NOT NULL REFERENCES flight(id),
fare_class VARCHAR(10) NOT NULL, -- ECONOMY, BUSINESS
capacity INT NOT NULL,
overbook_limit INT NOT NULL DEFAULT 0,
sold INT NOT NULL DEFAULT 0,
held INT NOT NULL DEFAULT 0,
PRIMARY KEY (flight_id, fare_class),
CHECK (sold + held <= capacity + overbook_limit)
);
CREATE TABLE seat (
flight_id BIGINT NOT NULL REFERENCES flight(id),
seat_no VARCHAR(4) NOT NULL,
status VARCHAR(10) NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, SOLD
booking_id BIGINT,
held_until TIMESTAMPTZ,
PRIMARY KEY (flight_id, seat_no)
);
CREATE TABLE booking (
id BIGINT PRIMARY KEY,
flight_id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
status VARCHAR(12) NOT NULL, -- HELD, CONFIRMED, EXPIRED, CANCELLED
amount NUMERIC(12,2) NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE payment (
id BIGINT PRIMARY KEY,
booking_id BIGINT NOT NULL,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(10) NOT NULL
);
The design has two layers of inventory:
fare_inventorycounts by ticket class. The CHECK constraint is the final safety net: even if the code has a bug, the database still rejects overbooking beyond the limit.seatmanages each seat specifically. The primary key(flight_id, seat_no)ensures each seat has only one status row.
4. API
GET /flights?from=SGN&to=HAN&date=2026-12-20 -> flight list (cached)
GET /flights/{id}/seats -> seat map (cached 2-5 seconds)
POST /bookings -> hold, returns bookingId + expiresAt
POST /bookings/{id}/payments -> Idempotency-Key header required
POST /payments/webhook -> payment gateway callback
DELETE /bookings/{id} -> cancel
Hold request:
{ "flightId": 1001, "customerId": 42, "fareClass": "ECONOMY", "seats": ["12A", "12B"] }
Seat maps can be stale by a few seconds. A user might see a seat as “available” and get rejected when trying to hold it. That’s an acceptable trade-off, as long as the hold step always re-checks on the database.
5. Hold: Preventing Duplicate Seat Sales
The most common mistake is “read then write”: SELECT status, see AVAILABLE, then UPDATE. Two transactions can both read empty and both write. Result: one seat sold to two people.
The solution is conditional update: merge the check condition into the UPDATE statement and count affected rows.
public interface SeatRepository extends JpaRepository<Seat, SeatId> {
@Modifying
@Query(value = """
UPDATE seat
SET status = 'HELD', booking_id = :bookingId, held_until = :until
WHERE flight_id = :flightId
AND seat_no IN (:seatNos)
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until < now()))
""", nativeQuery = true)
int holdSeats(Long flightId, List<String> seatNos, Long bookingId, Instant until);
@Modifying
@Query(value = """
UPDATE seat SET status = 'SOLD', held_until = NULL
WHERE booking_id = :bookingId AND status = 'HELD'
""", nativeQuery = true)
int markSold(Long bookingId);
}
public interface FareInventoryRepository extends JpaRepository<FareInventory, FareInventoryId> {
@Modifying
@Query(value = """
UPDATE fare_inventory SET held = held + :n
WHERE flight_id = :flightId AND fare_class = :fareClass
AND sold + held + :n <= capacity + overbook_limit
""", nativeQuery = true)
int reserve(Long flightId, String fareClass, int n);
}
PostgreSQL locks the row when executing UPDATE. The second transaction must wait, then re-evaluate the WHERE condition on new data. At that point, the condition fails, so the query returns 0 rows. The held_until < now() condition allows “stealing” back expired held seats without waiting for a cleanup job.
The service combines two steps in one transaction:
@Service
@RequiredArgsConstructor
public class BookingService {
private static final Duration HOLD_TTL = Duration.ofMinutes(10);
private final SeatRepository seatRepository;
private final FareInventoryRepository inventoryRepository;
private final BookingRepository bookingRepository;
private final IdGenerator idGenerator;
@Transactional
public Booking hold(HoldRequest req) {
int n = req.seats().size();
if (inventoryRepository.reserve(req.flightId(), req.fareClass(), n) != 1) {
throw new SoldOutException(req.flightId(), req.fareClass());
}
Instant until = Instant.now().plus(HOLD_TTL);
long bookingId = idGenerator.next();
int held = seatRepository.holdSeats(req.flightId(), req.seats(), bookingId, until);
if (held != n) {
// Rollback both reserve and held seats
throw new SeatUnavailableException(req.seats());
}
return bookingRepository.save(new Booking(bookingId, req.flightId(),
req.customerId(), BookingStatus.HELD, req.amount(), until));
}
}
SeatUnavailableException is a RuntimeException, so Spring rolls back the entire transaction. The customer either holds all selected seats or none. There’s no scenario where they hold 12A but lose 12B.
Note: To avoid deadlock when two customers pick {12A, 12B} and {12B, 12A}, sort
seatNosbefore sending down. Postgres doesn’t guarantee lock order by IN list. For large seat groups, useSELECT … ORDER BY seat_no FOR UPDATEbefore updating.
If a just-expired seat is stolen by someone else, the old booking’s held counter still remains. The cleanup job in section 7 will subtract it.
6. Idempotent Payment
The payment gateway can call the webhook multiple times. Users can click “Pay” twice. Every financial operation must be idempotent.
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final BookingRepository bookingRepository;
private final SeatRepository seatRepository;
private final FareInventoryRepository inventoryRepository;
private final OutboxRepository outboxRepository;
@Transactional
public PaymentResult confirm(long bookingId, String idempotencyKey) {
Optional<Payment> existing = paymentRepository.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) {
return PaymentResult.of(existing.get()); // repeat call: return old result
}
// HELD -> CONFIRMED, only succeeds if booking hasn't expired
int updated = bookingRepository.transition(bookingId, "HELD", "CONFIRMED");
if (updated == 0) {
paymentRepository.save(Payment.refundRequired(bookingId, idempotencyKey));
return PaymentResult.REFUND;
}
Booking booking = bookingRepository.findById(bookingId).orElseThrow();
int seats = seatRepository.markSold(bookingId);
inventoryRepository.moveHeldToSold(booking.getFlightId(), booking.getFareClass(), seats);
paymentRepository.save(Payment.success(bookingId, idempotencyKey));
outboxRepository.save(OutboxEvent.bookingConfirmed(bookingId));
return PaymentResult.SUCCESS;
}
}
The transition statement is just another conditional update:
UPDATE booking SET status = :to WHERE id = :id AND status = :from
The critical race is between “payment arrives” and “hold expires”. Both the payment flow and the expire job try to transition from HELD. Only one wins. If payment arrives late, the customer gets auto-refunded instead of a seat already sold to someone else.
The idempotency_key UNIQUE column prevents two requests with the same key from running in parallel. Both don’t see the old record, but the second request hits DataIntegrityViolationException on insert, and its transaction rolls back.
6.1. Outbox and Kafka
Issuing tickets, sending emails, and syncing to the check-in system all run asynchronously. Don’t send to Kafka directly in the transaction. If the DB commits but Kafka send fails (or vice versa), data will drift. Write to the outbox table in the same transaction, then a relay pushes to Kafka:
@Component
@RequiredArgsConstructor
public class OutboxRelay {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafka;
@Scheduled(fixedDelay = 500)
@Transactional
public void publish() {
for (OutboxEvent e : outboxRepository.findTop100ByPublishedFalseOrderById()) {
kafka.send("booking-events", String.valueOf(e.getBookingId()), e.getPayload()).join();
e.markPublished();
}
}
}
Relay ensures at-least-once, so the issuing consumer must also be idempotent, e.g., by tracking processed bookingIds. Using bookingId as the key ensures events for the same booking go to the same partition and maintain order.
7. Cleaning Up Expired Holds
@Scheduled(fixedDelay = 30_000)
@Transactional
public void expireHolds() {
for (Booking b : bookingRepository.findTop500ByStatusAndExpiresAtBefore(
BookingStatus.HELD, Instant.now())) {
if (bookingRepository.transition(b.getId(), "HELD", "EXPIRED") == 1) {
int released = seatRepository.releaseByBooking(b.getId()); // HELD -> AVAILABLE
inventoryRepository.release(b.getFlightId(), b.getFareClass(), b.getSeatCount());
}
}
}
The job subtracts held by the booking’s seatCount, not by the number of seats just released. The reason is that some seats might have been stolen by others earlier, but the counter still counts the old booking. When running multiple instances, use SELECT … FOR UPDATE SKIP LOCKED or ShedLock to prevent nodes from processing duplicates.
8. Handling Traffic Spikes
When opening cheap tickets, 50,000 requests/second all hit a few fare_inventory rows. That’s a hot row. Every transaction queues for the same row lock. The database doesn’t die from CPU, but from a drained connection pool.
The strategy is to filter before reaching the DB:
- Virtual waiting room: only N people/second enter the booking page, the rest get a queue token.
- Fast filter on Redis: lock seat with
SET NXto early-reject requests that will definitely lose. - DB is still the final arbiter.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
@Component
@RequiredArgsConstructor
public class SeatGate {
private final StringRedisTemplate redis;
public boolean tryEnter(long flightId, String seatNo, long customerId) {
String key = "seat:%d:%s".formatted(flightId, seatNo);
Boolean ok = redis.opsForValue()
.setIfAbsent(key, String.valueOf(customerId), Duration.ofSeconds(15));
return Boolean.TRUE.equals(ok);
}
}
If tryEnter returns false, the API immediately returns 409 Conflict without opening any transaction. 15-second TTL is enough for the DB hold step, and the key disappears if the instance crashes.
Note: Don’t make Redis the source of truth for inventory. During Redis failover, you might lose keys and let two people through the gate. That’s okay, because the conditional update on the DB still blocks it. Conversely, if you only trust Redis, you’ll sell duplicate seats.
For hot rows counted by class, you can split fare_inventory into multiple “buckets” (e.g., 10 rows, 18 seats each) and randomly pick a bucket to reduce lock contention.
9. Testing Against Duplicate Sales
The most important test: 50 threads all try to hold the same seat, only one thread wins. Run with real PostgreSQL via Testcontainers, because H2’s locking behavior differs from Postgres.
@SpringBootTest
@Testcontainers
class BookingServiceConcurrencyTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired BookingService bookingService;
@Test
void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
int threads = 50;
AtomicInteger success = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < threads; i++) {
long customerId = i;
pool.submit(() -> {
start.await();
try {
bookingService.hold(new HoldRequest(1001L, customerId, "ECONOMY",
List.of("12A"), new BigDecimal("1500000")));
success.incrementAndGet();
} catch (SeatUnavailableException | SoldOutException ignored) { }
return null;
});
}
start.countDown();
}
assertThat(success.get()).isEqualTo(1);
}
}
Test assumes sample data (flight 1001, seat 12A available) is loaded via Flyway or data.sql. CountDownLatch releases all threads at once to maximize contention. ExecutorService’s try-with-resources block (Java 21) waits for all tasks to finish before closing. Expected result: exactly one success, 49 times SeatUnavailableException.
10. Common Mistakes
- Using
synchronizedor locks in JVM: only works with one instance. Scale to two pods and it breaks. - Optimistic locking with
@Versionfor hot rows: logically correct, but with traffic spikes, retry rates are very high. Conditional update is more efficient because it doesn’t need a prior read. - Keeping transaction open while calling the payment gateway: row lock held for seconds. Instead, hold the seat, commit, call the gateway, then confirm in a new transaction.
- Forgetting to handle refunds after the hold expires: this is the biggest complaint source. Always have an auto-refund path.
- Caching seat maps too long: users keep picking already-sold seats. 2–5 seconds is enough.
11. Conclusion
The airline booking system revolves around one principle: separate the part that needs strong consistency (seat inventory, booking status) from the part that tolerates latency (search, notifications, ticket issuance). Conditional updates on the database resolve contention without distributed locks. State machine HELD → CONFIRMED/EXPIRED with conditional transitions handles the race between payment and expiry. Idempotency keys and outbox prevent money and events from being duplicated. Redis and virtual waiting rooms protect the database during traffic spikes.
You can apply this framework to any “scarce resource, many competing” problem: concert tickets, hotel rooms, doctor appointment slots, or flash sales.