Guide

A buyer needs a few minutes to pay, so seats must be held, not sold yet. A hold is a temporary claim with an expiry time. Each seat moves through three states: available, held and booked. A held seat whose hold has expired goes back to available, and nobody needs to tell it to.

Concept

Before you start

  • State machine: a set of states and the moves allowed between them. Here: available → held → booked, with held → available on expiry.
  • TTL (time to live): how long a claim stays valid before it expires.

The big idea

Think of a restaurant table reserved for ten minutes with a note reading "held until 7:10". Once 7:10 passes, anyone can take the table. The host does not have to walk the room first to check.

The picture breaks for display. The board showing which tables are free still needs updating, and that is a separate job that does not affect who gets the table.

Walking through it

The claim checks expiry in the same statement that takes the seat. A seat is claimable if it is available, or if it is held and its hold has passed:

SQL
UPDATE seats
SET status = 'held',
    hold_id = $hold_id,
    holder_user_id = $user,
    hold_until = now() + interval '10 minutes'
WHERE event_id = $1
  AND seat_id = ANY($seat_ids)
  AND (status = 'available' OR (status = 'held' AND hold_until < now()));
-- Require the row count to equal the number of seats requested

Here is one seat over time:

10:00:00  A holds 14C, hold_until = 10:10:00
10:04:00  B claims 14C → hold not expired → 0 rows, refused
10:10:01  B claims 14C → hold expired   → 1 row, B holds it

No job ran at 10:10. The expired hold simply stopped counting. A sweeper can still tidy stale hold records for the seat map, but correctness does not depend on it.

Redis and the database

Some teams keep active holds in Redis for speed, using a key with a TTL and an atomic SET … NX. Redis absorbs claim storms in memory, and the database handles the final commit.

Trade-off

A database-only hold is simpler and strongly consistent, but it sends every claim to the primary. Redis plus the database absorbs spikes. The cost is that a Redis flush forgets holds. Treat a missing Redis hold as expired, then check the database. The unique constraint on sold seats is the final backstop either way.

Use the database clock for expiry comparisons. Application servers can disagree with each other by enough to make holds expire early or late.

Common mistakes

  • Freeing seats only with a cron job. Between runs, a stale hold blocks a valid claim. Check expiry at claim time.
  • Keeping holds only in Redis. A flush erases every hold, so the database must know them too.
  • Skipping the expiry check at booking time. A buyer whose hold expired mid-payment must not be able to book.
  • Comparing times across machines. Use one clock, the database's.

Check yourself

1. A hold expires at 10:10. Is the seat claimable at 10:10:30 if nothing swept it?

Answer: Yes. The claim checks that hold_until has passed, so the seat is free without a sweeper.

2. Why keep the unique constraint on sold seats if holds already exist?

Answer: It is the last line of defence. Even if holds or the cache fail, the database refuses a second sale of the same seat.

Key takeaways

  • Model seats as available, held and booked, with an expiry on held seats.
  • Check expiry when claiming a seat, so inventory frees itself.
  • Redis can speed up holds, but the database has the final say.
  • Use the database clock for every expiry comparison.

Further learning

Next, see how a waiting room controls how many buyers reach the hold path in the virtual waiting room.