Movie ticket system design: seat holds and reservation expiry

1. Overview

When a blockbuster movie opens for ticket sales, thousands of customers rush to a seating map that has only a few hundred seats. The system must ensure each seat belongs to only one person. Seats are held temporarily while customers pay and must be automatically released if customers leave.

This article covers the complete design: requirements, data model, APIs, processing flow, and tricky points. Example code uses Java 21, Spring Boot 3.x, SQL, and Redis. After reading, you’ll be able to implement a safe seat-hold mechanism under high concurrent load. You’ll also understand how to handle expired holds without relying on a separate cleanup job.

2. Requirements

2.1. Functional Requirements

  • Customers view a seating map for a specific showtime, showing seat status as available, held, or booked.
  • Customers select up to 8 seats and hold them for 10 minutes.
  • Seat holding is all-or-nothing: if any seat can’t be held, no seats are held.
  • If customers pay during the hold period, seats become booked.
  • After 10 minutes without payment, seats automatically become available again.

2.2. Scale and Constraints

  • Around 500 cinemas, 10 theaters per cinema, 100–300 seats per theater.
  • Normal traffic is hundreds of requests/second. During blockbuster releases, traffic can spike to 50,000 requests/second, concentrated on a few showtimes.
  • No double-booking seats. This is a hard constraint.
  • Read-to-write ratio is heavily skewed: each seat hold is accompanied by dozens of seat-map views.
  • Payments go through an external gateway. Callbacks may arrive late, arrive twice, or never arrive.

3. Data Model

The central table is show_seat. Each row represents a seat in a specific showtime. We don’t have a separate “hold” table, because seat status and hold information must be updated atomically in a single statement.

CREATE TABLE show_seat (
    showtime_id     BIGINT       NOT NULL,
    seat_code       VARCHAR(8)   NOT NULL,
    status          VARCHAR(16)  NOT NULL DEFAULT 'AVAILABLE', -- AVAILABLE | HELD | BOOKED
    hold_id         UUID,
    customer_id     BIGINT,
    hold_expires_at TIMESTAMP,
    booking_id      BIGINT,
    PRIMARY KEY (showtime_id, seat_code)
);

CREATE INDEX idx_show_seat_hold ON show_seat (hold_id);
CREATE INDEX idx_show_seat_expired ON show_seat (status, hold_expires_at);

Key points:

  • The composite primary key (showtime_id, seat_code) allows fast updates of multiple seats in the same showtime.
  • hold_id groups seats from the same hold. The payment step will confirm the entire group using this hold_id.
  • The (status, hold_expires_at) index supports the expired-hold cleanup job in section 7.

The showtime, booking, and payment tables are standard CRUD tables, so we won’t detail them here.

4. APIs

GET    /showtimes/{showtimeId}/seats          -> seating map + status
POST   /showtimes/{showtimeId}/holds          -> hold seats, body: {"seats":["F5","F6"]}
                                                 201 {"holdId":"...","expiresAt":"..."} | 409 if unavailable
POST   /holds/{holdId}/checkout               -> create payment session (Idempotency-Key header)
DELETE /holds/{holdId}                        -> customer cancels, release seats immediately
POST   /payments/callback                     -> payment gateway callback

Return 409 Conflict when seats are already held by someone else. Clients use this status code to refresh the map and prompt customers to choose different seats.

5. Processing Flow

Customer        API                 DB / Redis              Payment gateway
  | selects     |                       |                          |
  | seats       |                       |                          |
  |------------>| conditional UPDATE    |                          |
  |             |---------------------->| HELD, expires in 10'     |
  |<------------| 201 holdId            |                          |
  | checkout    |                       |                          |
  |------------>| create payment (8')   |------------------------->|
  |             |                       |        callback SUCCESS  |
  |             |<-------------------------------------------------|
  |             | HELD → BOOKED if      |                          |
  |             | not expired           |                          |

The payment session is configured to expire after 8 minutes, shorter than the hold duration. The 2-minute buffer ensures most callbacks arrive before seats are released.

6. Locking Seats Temporarily

Dependencies needed:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

6.1. Conditional UPDATE as Source of Truth

A common mistake is read-then-write: SELECT to check if seats are available, then UPDATE. Between the two statements, another request can sneak in and hold the seat. Result: two people think they’ve held the same seat.

The correct approach is to embed the check in the WHERE condition and count affected rows:

@Service
public class SeatHoldService {

    private static final Duration HOLD_TTL = Duration.ofMinutes(10);

    private final NamedParameterJdbcTemplate jdbc;
    private final Clock clock;

    public SeatHoldService(NamedParameterJdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Transactional
    public SeatHold hold(long showtimeId, List<String> seatCodes, long customerId) {
        UUID holdId = UUID.randomUUID();
        Instant now = clock.instant();
        Instant expiresAt = now.plus(HOLD_TTL);

        var params = new MapSqlParameterSource()
                .addValue("holdId", holdId)
                .addValue("customerId", customerId)
                .addValue("showtimeId", showtimeId)
                .addValue("seatCodes", seatCodes.stream().sorted().toList())
                .addValue("now", Timestamp.from(now))
                .addValue("expiresAt", Timestamp.from(expiresAt));

        int updated = jdbc.update("""
                UPDATE show_seat
                   SET status = 'HELD', hold_id = :holdId,
                       customer_id = :customerId, hold_expires_at = :expiresAt
                 WHERE showtime_id = :showtimeId
                   AND seat_code IN (:seatCodes)
                   AND (status = 'AVAILABLE'
                        OR (status = 'HELD' AND hold_expires_at < :now))
                """, params);

        if (updated != seatCodes.size()) {
            throw new SeatUnavailableException(showtimeId, seatCodes);
        }
        return new SeatHold(holdId, expiresAt);
    }
}

public record SeatHold(UUID holdId, Instant expiresAt) {}

public class SeatUnavailableException extends RuntimeException {
    public SeatUnavailableException(long showtimeId, List<String> seats) {
        super("Seats " + seats + " of showtime " + showtimeId + " are not available");
    }
}

How the code works:

  • The UPDATE acquires row locks on each seat row. Two transactions targeting seat F5 must queue. The second transaction re-evaluates the WHERE condition and sees status = 'HELD', so it can’t update that row.
  • The condition status = 'HELD' AND hold_expires_at < :now implements lazy expiration. An expired hold is treated as available, even without a cleanup job.
  • If fewer rows are updated than requested, an exception is thrown. @Transactional rolls back everything, giving us atomicity.
  • Clock is injected so tests can control time.

Suppose customer A holds F5, F6, then customer B tries to hold F6, F7. B receives:

HTTP 409 Conflict
{"error":"Seats [F6, F7] of showtime 42 are not available"}

Seat F7 of B remains available, because B’s transaction was rolled back.

Note: Sort seatCodes before updating. If A locks in order F5 → F6 and B locks in order F6 → F5, the transactions may deadlock. With PostgreSQL, lock order depends on row scan order. To be safe, run SELECT ... ORDER BY seat_code FOR UPDATE before updating.

6.2. Redis as a Fast Gating Layer

During traffic spikes, 50,000 seat-hold requests hit the same few hundred rows of one showtime. Database lock queues become very long. We put Redis in front to quickly reject requests that will surely fail. The Lua script below makes multi-key operations atomic:

@Component
public class RedisSeatGate {

    private static final RedisScript<Long> TRY_HOLD = new DefaultRedisScript<>("""
            for _, key in ipairs(KEYS) do
              if redis.call('EXISTS', key) == 1 then return 0 end
            end
            for _, key in ipairs(KEYS) do
              redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
            end
            return 1
            """, Long.class);

    private final StringRedisTemplate redis;

    public RedisSeatGate(StringRedisTemplate redis) {
        this.redis = redis;
    }

    public boolean tryHold(long showtimeId, List<String> seats, UUID holdId, Duration ttl) {
        List<String> keys = seats.stream()
                .map(s -> "seat:{" + showtimeId + "}:" + s)
                .toList();
        Long ok = redis.execute(TRY_HOLD, keys, holdId.toString(),
                String.valueOf(ttl.toMillis()));
        return ok != null && ok == 1L;
    }
}

How it works:

  • Redis runs the Lua script on a single thread. No request can interleave between the EXISTS check and the SET.
  • PX attaches a TTL to keys, so Redis automatically deletes keys when they expire. This is Redis’s native expiration mechanism.
  • The {showtimeId} part is a hash tag. In Redis Cluster, all keys for one showtime land in the same slot. Without it, the multi-key script will error with CROSSSLOT.

The full flow is: call tryHold on Redis first. If it fails, return 409 immediately. If it succeeds, run the UPDATE from section 6.1. If UPDATE fails, delete the Redis key we just set.

Note: Redis is not the source of truth. Redis can lose data on failover, and key expiration may drift milliseconds from the DB. The final decision is always the conditional UPDATE. If your system doesn’t have traffic spikes, skip Redis entirely to reduce operational overhead.

7. Expiring Holds

The correctness of expiration is guaranteed by the hold_expires_at < :now condition in section 6.1. However, the seating map still shows HELD until someone overwrites it. So we need a cleanup job for the UI to display correctly:

@Component
public class ExpiredHoldSweeper {

    private final JdbcTemplate jdbc;
    private final Clock clock;

    public ExpiredHoldSweeper(JdbcTemplate jdbc, Clock clock) {
        this.jdbc = jdbc;
        this.clock = clock;
    }

    @Scheduled(fixedDelay = 15_000)
    public void releaseExpired() {
        int released = jdbc.update("""
                UPDATE show_seat
                   SET status = 'AVAILABLE', hold_id = NULL,
                       customer_id = NULL, hold_expires_at = NULL
                 WHERE status = 'HELD' AND hold_expires_at < ?
                """, Timestamp.from(clock.instant()));
        if (released > 0) {
            // publish SeatsReleased event to refresh seat-map cache
        }
    }
}

This job runs every 15 seconds. It’s fine if it runs late or on multiple instances simultaneously, because the statement is idempotent. Thanks to lazy expiration, even if the job is down for a few minutes, no double-booking occurs. The only consequence is stale UI display during that window. Remember to enable @EnableScheduling in your config.

The payment confirmation side must check expiration in the same statement:

@Transactional
public boolean confirm(UUID holdId, long bookingId) {
    int updated = jdbc.update("""
            UPDATE show_seat
               SET status = 'BOOKED', booking_id = :bookingId, hold_expires_at = NULL
             WHERE hold_id = :holdId AND status = 'HELD'
               AND hold_expires_at >= :now
            """, new MapSqlParameterSource()
                    .addValue("holdId", holdId)
                    .addValue("bookingId", bookingId)
                    .addValue("now", Timestamp.from(clock.instant())));
    return updated > 0;
}

This method returns false in two cases: the hold has expired, or the seat was re-held by someone else. Both need to be handled in the payment flow.

8. Consistency with Payment

Payment callbacks are the trickiest part. You need to handle three scenarios:

  1. Callback arrives twice. Store payment_id with a UNIQUE constraint. The second time, see the payment is already processed, return 200, and do nothing.
  2. Callback arrives after the hold expires. confirm returns false. The system charged the customer but can’t deliver seats. Mark the booking FAILED and issue an automatic refund. Don’t try to “claim back” the seat, because someone else may have already bought it.
  3. Callback never arrives. A periodic reconciliation job queries the payment gateway for PENDING payments older than 15 minutes and applies the logic from the two cases above.

After successful confirm, operations like sending email, generating QR tickets, or adding loyalty points should not run in the same transaction. Instead, write a BookingConfirmed row to an outbox table in that transaction. A separate process pushes events to Kafka with topic booking-events and key bookingId. This ensures events are only published after DB commit, and if commit succeeds, the event is guaranteed to be published.

9. Handling Traffic Spikes

The Redis layer from section 6.2 handles write traffic. For blockbuster releases, add these measures:

  • Virtual waiting room. For hot showtimes, only N users can access the seat-selection page at once. Others get a queue token stored in a Redis sorted set scored by arrival time. A few hundred seats don’t need 50,000 people choosing simultaneously.
  • Cache the seating map. Serve maps from Redis or CDN and refresh them via SeatsHeld/SeatsReleased events. A 1–2 second delay is acceptable because the hold step re-validates exactly.
  • Per-customer rate limiting. For example, max 5 hold attempts per minute to block scraper bots.
  • Shard by showtime_id. All contention is within a single showtime. You can shard the DB by showtime_id without distributed transactions.

10. Testing Concurrency

The most important behavior to verify: when many customers try to hold the same seat, only one succeeds.

@SpringBootTest
class SeatHoldServiceTest {

    @Autowired SeatHoldService service;
    @Autowired JdbcTemplate jdbc;

    @BeforeEach
    void setUp() {
        jdbc.update("DELETE FROM show_seat");
        jdbc.update("INSERT INTO show_seat (showtime_id, seat_code) VALUES (42, 'F5'), (42, 'F6')");
    }

    @Test
    void onlyOneCustomerCanHoldTheSameSeat() throws Exception {
        int customers = 20;
        var start = new CountDownLatch(1);
        var successes = new AtomicInteger();

        try (var pool = Executors.newFixedThreadPool(customers)) {
            for (int i = 0; i < customers; i++) {
                long customerId = i;
                pool.submit(() -> {
                    start.await();
                    try {
                        service.hold(42, List.of("F5", "F6"), customerId);
                        successes.incrementAndGet();
                    } catch (SeatUnavailableException ignored) { }
                    return null;
                });
            }
            start.countDown();
        }

        assertThat(successes.get()).isEqualTo(1);
        assertThat(jdbc.queryForObject(
                "SELECT COUNT(DISTINCT hold_id) FROM show_seat WHERE status = 'HELD'",
                Integer.class)).isEqualTo(1);
    }
}

Test explanation:

  • CountDownLatch holds 20 threads at the starting line then releases them simultaneously to maximize contention.
  • try-with-resources on ExecutorService (Java 21) waits for all tasks to complete before running assertions.
  • The second assertion verifies both seats belong to the same hold_id, ensuring no “split holds”.

The test runs on H2 with schema.sql from section 3 (use H2’s UUID type, which is supported). You need to declare a Clock.systemUTC() bean. To test expiration, replace it with a test Clock that can advance time.

Note: H2 doesn’t accurately model PostgreSQL or MySQL locking behavior. For concurrency-related code, also run tests against your actual production database, using tools like Testcontainers.

11. Common Mistakes

  • Check-then-act (SELECT then UPDATE without locks). This is the #1 cause of double-booking.
  • Relying entirely on a cleanup job to release seats. If the job dies, seats stay locked. Lazy expiration in the WHERE clause eliminates this risk.
  • Using Redis as the source of truth. Locks can be lost on failover, leading to double-booking.
  • Holding SELECT ... FOR UPDATE locks for 10 minutes while waiting for payment. Connection pools drain fast. DB locks should live only milliseconds. Long hold times must be represented as data, specifically the hold_expires_at column.
  • Using timestamps from different machines. Standardize on one time source: either DB’s now(), or app Clock with tightly synchronized NTP.

12. Conclusion

The heart of the ticket system is a conditional UPDATE statement. It atomically locks seats and treats expired holds as available, so double-booking is impossible and no cleanup job is required. Everything else is built around this core:

  • Redis Lua script gates traffic early.
  • Sweeper job keeps the UI consistent.
  • Idempotency and reconciliation handle payment callback quirks.
  • Outbox + Kafka reliably publishes events.
  • Virtual waiting room absorbs traffic spikes.

When implementing, start with the SQL core and concurrency test. Add Redis and the waiting room only when real load metrics show you need them.

system-designspring-bootredisconcurrencycase-study