Skip to content

The Reservation Pattern

TL;DR

Hard locks (SELECT FOR UPDATE) block other users while one person holds the lock. For user-facing systems like ticket booking, that's terrible UX — you can't lock a database row while someone takes 5 minutes to enter their credit card. The reservation pattern uses soft locks with time limits: reserve the resource for N minutes, convert to a purchase if payment succeeds, auto-release if the timer expires. It separates the "claim" from the "commit" and puts a countdown between them.

Your Database Lock Is Holding 10,000 People Hostage

You learned SELECT FOR UPDATE in the last lesson. It solves the race condition — only one transaction can modify the locked row at a time. But here's the problem nobody mentions in textbook examples:

What happens between "User clicks Buy" and "User finishes entering their credit card"?

Timeline of a real purchase flow:

User clicks "Buy Seat A15"     → SELECT FOR UPDATE on seat A15
User stares at payment form    → ... lock held ...
User searches for credit card  → ... lock held ...
User types card number wrong   → ... lock held ...
User re-types card number      → ... lock held ...
User clicks "Pay"              → UPDATE seat, COMMIT, lock released

Total lock duration: 3-10 minutes

During those 3-10 minutes, every other user trying to buy seat A15 — or any seat in the same row range — is blocked. Their queries are waiting on your lock. Their HTTP connections are hanging. Your connection pool is filling up.

What Goes Wrong Why It Hurts
Lock held for minutes instead of milliseconds Other transactions queue up waiting
User abandons the page (closes tab) Lock held until DB timeout (30-60 seconds), not until the user leaves
100 users trying to buy the same seat 99 connections blocked, connection pool exhausted
Connection pool exhausted Entire application stops responding — not just ticket purchases

The Cascade Failure

One user holding a database lock for 5 minutes doesn't just affect people buying that seat. If your connection pool has 20 connections and 20 users are all holding locks on different seats while entering credit card info, no new database connections are available. Your homepage stops loading. Your search stops working. Your entire site goes down because 20 people are slowly typing their credit card numbers.

Hard locks are built for millisecond-duration operations — decrement a counter, transfer money between accounts, update a balance. They were never designed for minute-duration user flows where a human is in the loop.

The Two-Phase Fix: Reserve Now, Pay Later

The reservation pattern splits the purchase into two distinct phases, with a countdown timer between them.

Phase 1 — Reserve: User clicks "Buy" → system creates a soft lock (a row in a reservations table) with an expiration time. No database lock is held. The seat is marked "reserved" but the reservation has a ticking clock.

Phase 2a — Confirm: User completes payment within the time limit → reservation converts to a confirmed purchase. Done.

Phase 2b — Expire: Timer runs out before payment → reservation is automatically deleted → seat becomes available again for someone else.

Reservation pattern state diagram: Available to Reserved to Confirmed

The key insight: no database lock is ever held during the user flow. The "lock" is just a row in a table with an expires_at timestamp. Other transactions can read and write freely. The only thing that's blocked is another user trying to reserve the same seat, and that's handled with a simple unique constraint — not a database lock.

The SQL That Makes It Work

Phase 1: Create the Reservation (Soft Lock)

-- Attempt to reserve seat A15 for user123
-- TTL: 10 minutes from now
INSERT INTO reservations (seat_id, user_id, expires_at, status)
VALUES ('A15', 'user123', NOW() + INTERVAL '10 minutes', 'reserved')
ON CONFLICT (seat_id)
DO NOTHING;
-- If seat already reserved by someone else, this silently fails
-- Check: 1 row affected = got it, 0 rows affected = someone else has it

Why ON CONFLICT DO NOTHING instead of raising an error? Because the seat being unavailable isn't an error — it's an expected outcome. The application checks the affected row count and shows the user "This seat is no longer available" instead of crashing.

cursor.execute("""
    INSERT INTO reservations (seat_id, user_id, expires_at, status)
    VALUES (%s, %s, NOW() + INTERVAL '10 minutes', 'reserved')
    ON CONFLICT (seat_id) DO NOTHING
""", (seat_id, user_id))

if cursor.rowcount == 0:
    return {"error": "Seat already reserved. Try another seat."}
else:
    return {"status": "reserved", "expires_in": 600}

Phase 2a: Convert to Confirmed Purchase

-- User completed payment — convert reservation to purchase
UPDATE reservations
SET status = 'confirmed', confirmed_at = NOW()
WHERE seat_id = 'A15'
  AND user_id = 'user123'
  AND status = 'reserved'
  AND expires_at > NOW();

Every condition in that WHERE clause matters:

Condition What It Prevents
seat_id = 'A15' Confirming a different seat
user_id = 'user123' Another user hijacking this reservation
status = 'reserved' Double-confirming an already-confirmed purchase
expires_at > NOW() Confirming after the timer expired (seat may be re-reserved)

If this UPDATE affects 0 rows, the reservation expired while the user was paying. The application should refund the payment and tell the user the seat is no longer available.

Phase 2b: Cleanup Expired Reservations

-- Background job — runs every minute
DELETE FROM reservations
WHERE status = 'reserved'
  AND expires_at < NOW();

This is a simple cron job or background worker. It sweeps through the table, finds reservations where the timer has expired and the user never paid, and deletes them — making those seats available again.

Lazy vs Eager Expiration

You don't strictly need the background cleanup job. You could use lazy expiration — when someone tries to reserve a seat, first check if the existing reservation has expired:

INSERT INTO reservations (seat_id, user_id, expires_at, status)
VALUES ('A15', 'user123', NOW() + INTERVAL '10 minutes', 'reserved')
ON CONFLICT (seat_id)
DO UPDATE SET
    user_id = 'user123',
    expires_at = NOW() + INTERVAL '10 minutes',
    status = 'reserved'
WHERE reservations.expires_at < NOW();

This overwrites expired reservations in-place. But in practice, you want both: lazy expiration for correctness (so a new user can grab an expired seat immediately) and eager expiration via a background job for cleanliness (so your table doesn't fill up with stale rows).

The Schema

CREATE TABLE reservations (
    seat_id     VARCHAR(10) PRIMARY KEY,  -- one reservation per seat
    user_id     VARCHAR(64) NOT NULL,
    status      VARCHAR(20) NOT NULL DEFAULT 'reserved',
    expires_at  TIMESTAMPTZ NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    confirmed_at TIMESTAMPTZ,

    CONSTRAINT valid_status CHECK (status IN ('reserved', 'confirmed'))
);

-- Index for the background cleanup job
CREATE INDEX idx_reservations_expiry
ON reservations (expires_at)
WHERE status = 'reserved';

The seat_id being the PRIMARY KEY is the entire concurrency control mechanism. Two users cannot insert the same seat_id — the unique constraint enforces mutual exclusion without any database locks.

How Long Should the Timer Be?

This is a product decision disguised as a technical one. Set it too short and users can't finish paying. Set it too long and seats are held by abandoned carts while real buyers see "sold out."

Domain Typical TTL Why
Concert tickets 10 minutes Ticketmaster's standard. Payment flow is simple (card on file).
Airline seats 15-20 minutes More complex forms (passenger details, luggage, meals).
Hotel rooms 30 minutes Multiple rooms, dates, and add-ons to configure.
Restaurant reservations 5 minutes Just confirming a time slot, minimal input required.
Shopping carts (e-commerce) No reservation Items aren't reserved. "Low stock" warning instead. Reduces abandoned-cart lockout.

Ticketmaster gives you exactly 10 minutes to complete your purchase. That countdown timer on the payment page isn't just UX — it's the reservation pattern in action. When the timer hits zero, your seat goes back into the pool, and the next person in the queue gets a shot.

The TTL Trade-off

Too short (2 minutes): Users with slow internet, accessibility needs, or complex payment methods (PayPal redirect, 3D Secure verification) can't finish in time. They lose their seat and have to start over. Frustrating UX, angry support tickets.

Too long (30 minutes for a concert): A user selects 4 seats, starts the payment flow, then gets a phone call and walks away. Those 4 seats are locked for 30 minutes. Multiply by thousands of users and you have a significant chunk of inventory invisible to real buyers. Lower conversion rate, lower revenue.

The sweet spot: Measure the 95th percentile of your payment completion time, add a small buffer. That's your TTL.

The Thundering Herd on Release

Here's a scenario nobody thinks about until it happens in production:

A prime seat (front row center) was reserved but the timer just expired. The background job releases it. At that exact moment, 500 users who've been refreshing the page see the seat appear. All 500 hit "Reserve" simultaneously.

Thundering herd on reservation release: 500 users competing for a released seat

The database handles this correctly — the unique constraint means exactly one INSERT succeeds. But 499 failed requests still hit your database simultaneously. At scale, this is a thundering herd.

The Fix: Queue-Based Admission

Instead of letting everyone fight over the seat directly, put a queue in front of the reservation system. Only let N users into the purchase flow at a time.

Without Queue With Queue
500 users hit the DB simultaneously 500 users enter a virtual queue
499 get instant rejection Next 5 users are admitted to the purchase page
DB handles 500 writes in a burst If they don't complete, next 5 are admitted
Thundering herd on every release Smooth, controlled load

Ticketmaster's Verified Fan program and virtual queue system is exactly this pattern. During the Taylor Swift Eras Tour presale, they had 14 million users in queue. The queue controlled admission — only a managed number of users were in the "reservation" phase at any given time. The reservation pattern handled the rest: 10-minute TTL, auto-release on expiry, and the next batch admitted from the queue.

Queue + Reservation = The Full Pattern

In high-demand systems, the reservation pattern is never deployed alone. It's always queue → reservation → payment → confirmation. The queue controls how many users enter the reservation phase. The reservation controls how long they have. Together, they turn an uncontrollable stampede into a manageable flow.

Reservation vs Hard Lock — The Decision Framework

Factor Hard Lock (FOR UPDATE) Reservation Pattern
Lock duration Milliseconds (DB transaction) Minutes (user flow)
What's blocked Other DB transactions wait Other users see "unavailable" instantly
Timeout mechanism DB lock timeout (seconds) Application-defined TTL (minutes)
User visibility Invisible — user doesn't know a lock exists Visible — countdown timer, "held for 10 min"
Failure mode Connection pool exhaustion, cascading failures Graceful — expired reservations auto-release
Implementation SELECT FOR UPDATE (1 SQL statement) Reservations table + background cleanup job
Best for Inventory decrement, bank transfer, counter update Seat booking, appointment scheduling, cart holds
Human in the loop? No — fully automated, sub-second Yes — user needs time to complete a form

The rule of thumb: if a human is between the "claim" and the "commit," use the reservation pattern. If it's machine-to-machine with no user interaction, a hard lock inside a transaction is simpler and sufficient.

A Real-World Example: Appointment Scheduling

The pattern isn't just for concerts. Here's a doctor's appointment booking system using the same structure:

-- Patient selects 2:00 PM on Thursday
INSERT INTO appointment_holds (
    doctor_id, slot_time, patient_id, expires_at, status
)
VALUES (
    'dr_smith', '2024-03-15 14:00:00',
    'patient_456', NOW() + INTERVAL '5 minutes',
    'held'
)
ON CONFLICT (doctor_id, slot_time)
DO NOTHING;
-- Patient confirms (fills in reason for visit, insurance info)
UPDATE appointment_holds
SET status = 'confirmed', confirmed_at = NOW()
WHERE doctor_id = 'dr_smith'
  AND slot_time = '2024-03-15 14:00:00'
  AND patient_id = 'patient_456'
  AND status = 'held'
  AND expires_at > NOW();

Same pattern, different domain. The TTL here is 5 minutes because confirming an appointment is simpler than entering payment info — you just need to fill in a reason for the visit.

Common Mistakes

Mistake 1: No Expiration

Creating a reservation with no expires_at. If the user never comes back, the seat is locked forever. Every reservation must have a TTL.

Mistake 2: Trusting the Client Timer

Showing a 10-minute countdown in JavaScript but not checking expires_at > NOW() on the server. A user can modify the client timer or replay the confirmation request after expiry. The server must be the source of truth.

Mistake 3: Confirming Without Checking the User

Forgetting AND user_id = 'user123' in the confirmation UPDATE. Without it, User B could confirm User A's reservation by guessing the seat ID. Always verify ownership.

Mistake 4: No Background Cleanup

Relying solely on lazy expiration. If nobody tries to reserve the expired seat, it stays in the table indefinitely. The background job is essential for keeping inventory accurate and the table clean.

Quick Recap

Concept Key Takeaway
Hard lock problem SELECT FOR UPDATE holds DB locks for the duration of the transaction — catastrophic if a human is in the loop
Two-phase approach Phase 1: reserve with TTL. Phase 2: confirm or auto-expire
Soft lock mechanism Unique constraint on seat_id — no database lock needed
TTL selection Measure 95th percentile payment time + buffer. Domain-specific (5-30 min)
Thundering herd Queue-based admission controls how many users enter the reservation phase
Eager + lazy expiration Background job cleans up stale rows. Lazy expiration handles edge cases in real-time

Interview Tip

Any time the interviewer mentions booking, reservations, or limited inventory with a user flow — reach for the reservation pattern. It shows you understand that database locks are for transactions, not for user interactions. Say: "I'd use a soft lock with a TTL — reserve the seat for 10 minutes, run a background job to expire abandoned reservations, and use a unique constraint to prevent double-booking. For high-demand scenarios, I'd add a queue in front to control admission." That single answer covers concurrency control, resource management, and graceful degradation — three things interviewers love to hear together.