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_idgroups seats from the same hold. The payment step will confirm the entire group using thishold_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
UPDATEacquires row locks on each seat row. Two transactions targeting seatF5must queue. The second transaction re-evaluates theWHEREcondition and seesstatus = 'HELD', so it can’t update that row. - The condition
status = 'HELD' AND hold_expires_at < :nowimplements 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.
@Transactionalrolls back everything, giving us atomicity. Clockis 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
seatCodesbefore updating. If A locks in orderF5 → F6and B locks in orderF6 → F5, the transactions may deadlock. With PostgreSQL, lock order depends on row scan order. To be safe, runSELECT ... ORDER BY seat_code FOR UPDATEbefore 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
EXISTScheck and theSET. PXattaches 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 withCROSSSLOT.
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:
- Callback arrives twice. Store
payment_idwith aUNIQUEconstraint. The second time, see the payment is already processed, return200, and do nothing. - Callback arrives after the hold expires.
confirmreturnsfalse. The system charged the customer but can’t deliver seats. Mark the bookingFAILEDand issue an automatic refund. Don’t try to “claim back” the seat, because someone else may have already bought it. - Callback never arrives. A periodic reconciliation job queries the payment gateway for
PENDINGpayments 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/SeatsReleasedevents. 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 byshowtime_idwithout 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:
CountDownLatchholds 20 threads at the starting line then releases them simultaneously to maximize contention.try-with-resourcesonExecutorService(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
WHEREclause eliminates this risk. - Using Redis as the source of truth. Locks can be lost on failover, leading to double-booking.
- Holding
SELECT ... FOR UPDATElocks 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 thehold_expires_atcolumn. - Using timestamps from different machines. Standardize on one time source: either DB’s
now(), or appClockwith 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.