Skip to content
All posts

Building a Seat Reservation System for 50,000 Concurrent Buyers

May 23, 2026·Read on Medium·

The seat hold window is where every booking system either earns trust or loses it.

Tickets go on sale at noon. You have 5,000 seats. You have 50,000 people all clicking the same button at the same second.

Three seconds later, you have a problem you didn’t know you had.

The seats aren’t sold out. The seats are just… occupied. Every one of them is locked inside an open database transaction by a user who clicked “Select Seat” and hasn’t done anything since. Your payment page is loading for forty seconds. Half your users are getting blank screens. The other half are watching a spinner next to a seat that is technically available but practically unreachable.

This is the seat reservation problem, and it’s not a locking problem. It’s a state design problem. The lock is a symptom. The missing seat hold lifecycle is the disease.

Requirements

Functional:

  • Users can browse seat availability in real time
  • Users can select one or more seats and enter a temporary hold
  • Held seats are unavailable to other buyers for a fixed window (10 minutes)
  • Users can complete payment to confirm seats, converting the hold to a confirmed booking
  • Holds that expire without payment are released back to the available pool
  • Users can cancel confirmed bookings within a defined window

Non-functional:

  • No seat must ever be double-booked
  • Availability checks must return in under 200ms under peak load
  • The system must support 50,000 concurrent users competing for a finite seat inventory
  • Hold expiry must be deterministic, not dependent on user action
  • The system must survive a payment service timeout without leaving seats orphaned indefinitely

Scale Estimation

Design for a major live-event venue: 5,000 seats per event, 50,000 concurrent buyers at the moment of sale.

Availability reads: At peak, assume every user checks availability every 5 seconds. That’s 10,000 read requests per second to the availability endpoint. This is where your cache lives.

Hold writes: Each user clicking a seat triggers at most one write per 10 minutes. At 50,000 users with a natural arrival spread, you might see 5,000 hold creation attempts in the first second of peak: roughly 5,000 writes/sec to your seat hold table. This is the hot path. This is where naive locking breaks.

Storage: A seat row is small. seat_id, event_id, status, held_by, held_until, booking_id: 150 bytes per row at generous estimates. 5,000 seats x 150 bytes = 750KB per event. A full database with 1,000 concurrent events and 5,000 seats each is 750MB. Fits comfortably in memory. This matters because you're going to want to cache aggressively.

Hold records: Similar size. At peak, you hold at most 5,000 entries per event. Redis handles this trivially.

High-Level Architecture

The system has four logical layers, each solving a distinct problem:

Layer 1: Availability Cache Serves seat status to browsers and mobile clients. Backed by Redis with a short TTL (3 to 5 seconds). Returns seat map: available, held, or confirmed. Does not participate in the booking transaction.

Layer 2: Seat Hold Service Accepts hold requests, validates that the seat is available, and atomically reserves it for the requesting user. This is the only layer that mutates seat state. All concurrency decisions live here.

Layer 3: Payment Service Handles payment collection and, on success, transitions a held seat to confirmed. On failure or timeout, leaves the hold to expire naturally. Idempotent: submitting the same booking reference twice does nothing harmful.

Layer 4: Hold Expiry Worker A background job that runs continuously, scanning for holds where held_until < NOW() and status is still held. Transitions them back to available and invalidates the availability cache. This is your safety net.

Data flows one way: a seat starts available, transitions to held when a user claims it, transitions to confirmed when payment clears, and returns to available when a hold expires or a booking is cancelled.

Deep-Dive: The Seat Hold Lifecycle

Most articles on seat reservation focus on the wrong moment. They ask “how do I prevent two users from booking the same seat at the same time?” That’s the wrong question. The right question is: “what does a seat look like between the moment someone clicks it and the moment they pay?”

That window is your problem. It’s a minimum of 30 seconds (for a fast user with a saved card) and often 8 to 10 minutes (for a new user entering card details, picking delivery options, reading the refund policy). During that window, the seat must appear unavailable to everyone else. And if the user never pays, the seat must come back.

You need a hold state.

Schema:

CREATE TABLE seats (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL,
seat_number VARCHAR(10) NOT NULL,
section VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
held_by UUID, -- user_id during hold
held_until TIMESTAMPTZ, -- expiry time of hold
booking_id UUID, -- foreign key on confirmation
version INTEGER NOT NULL DEFAULT 0, -- for optimistic locking
CONSTRAINT chk_status CHECK (status IN ('available', 'held', 'confirmed', 'cancelled'))
);

CREATE INDEX idx_seats_event_status ON seats (event_id, status);
CREATE INDEX idx_seats_held_until ON seats (held_until) WHERE status = 'held';

The held_until index with a partial condition (WHERE status = 'held') is not an accident. Your expiry worker queries WHERE status = 'held' AND held_until < NOW() constantly. Without that index, it scans the entire seats table every time it runs.

The hold creation path:

When a user selects a seat, your Hold Service needs to:

  1. Confirm the seat is still available
  2. Mark it held with the current user and an expiry timestamp
  3. Do both atomically, so no other request can sneak in between steps 1 and 2

In PostgreSQL, that’s one UPDATE with a WHERE status = 'available' guard:

UPDATE seats
SET
status = 'held',
held_by = $1, -- user_id
held_until = NOW() + INTERVAL '10 minutes',
version = version + 1
WHERE
id = $2
AND status = 'available'
RETURNING id, seat_number, held_until;

If another transaction already marked this seat held, the WHERE status = 'available' filter matches zero rows. The RETURNING clause returns nothing. You detect this, return a "seat no longer available" error, and tell the user to pick again. No explicit locking required. This is optimistic concurrency. You skip the lock entirely by making the write conditional.

Deep-Dive: What Breaks at Scale, and Why SKIP LOCKED Fixes It

Optimistic concurrency above handles the case where two users compete for the same specific seat. The user who wins the race gets the seat. The loser retries.

The harder problem is this: a user doesn’t care which specific seat they get. They want “two adjacent seats in Section B.” Your query looks like:

SELECT id, seat_number FROM seats
WHERE event_id = $1
AND section = 'B'
AND status = 'available'
LIMIT 2
FOR UPDATE;

At noon with 50,000 concurrent buyers, fifty transactions execute this query simultaneously. Every one of them finds the same two available seats in Section B. Every one of them tries to lock those same two rows. The first transaction acquires the lock and proceeds. The other forty-nine wait.

Not for a moment. They wait until transaction one commits. Then they all wake up, re-check the WHERE clause (those seats are now held), find nothing, and scan for the next pair. All at the same moment. You've just created a thundering herd inside your database.

This is where SKIP LOCKED changes the picture:

-- Without SKIP LOCKED: 49 transactions pile up waiting for 2 locked seats
SELECT id, seat_number FROM seats
WHERE event_id = $1
AND section = 'B'
AND status = 'available'
LIMIT 2
FOR UPDATE;

-- With SKIP LOCKED: each transaction takes a different pair and moves on
SELECT id, seat_number FROM seats
WHERE event_id = $1
AND section = 'B'
AND status = 'available'
LIMIT 2
FOR UPDATE SKIP LOCKED;

SKIP LOCKED tells PostgreSQL: if any row I want to lock is already locked by another transaction, skip it and look at the next one. Transactions no longer compete for the same rows. Transaction 1 locks seats B1 and B2. Transaction 2 skips those and locks B3 and B4. Transaction 3 locks B5 and B6. They all proceed in parallel.

One important caveat: SKIP LOCKED returns a deliberately inconsistent view of the data. That's the whole point. You are not guaranteed to see all available seats. Only the ones nobody else is currently processing. This is exactly what you want for a seat assignment queue. It would be the wrong choice for a query where correctness depends on seeing a consistent snapshot.

When to use which approach:

Use the conditional UPDATE ... WHERE status = 'available' (optimistic, no lock):

  1. The user selected a specific seat they want by seat number
  2. Conflicts are expected to be rare (less than 5% of requests)
  3. You’re fine telling the user “that seat just went, pick another”

Use FOR UPDATE SKIP LOCKED + immediate UPDATE:

  1. You’re assigning “any available seat” from a pool (best-available allocation)
  2. Conflicts are frequent (high concurrency, limited inventory)
  3. You want zero wait time; transactions must never block each other

Use FOR UPDATE NOWAIT (a third option, different again):

  1. You want the specific row or an instant error: no waiting, no skipping
  2. Useful when you need to detect contention and surface it immediately
  3. Returns a lock failure error that your application can catch and handle

Deep-Dive: Redis for the Hold Cache

PostgreSQL handles the authoritative state. Redis handles the speed. They’re solving different problems at different layers, and treating them as interchangeable is how teams end up with either a slow system or an inconsistent one.

Checking seat availability is a read-heavy operation. At 10,000 read requests per second, hitting PostgreSQL for every availability check turns your database into a bottleneck. Cache the seat map in Redis with a 3-second TTL. A user sees seat B7 as “available” in the UI. Three seconds later it might already be held. That’s fine. The conditional UPDATE in PostgreSQL catches it.

The Redis layer also handles a specific optimization for the hold path. Before your application even touches PostgreSQL to attempt a hold, try to acquire a Redis lock on the seat:

SET seat:hold:{event_id}:{seat_id} {user_id} NX EX 600

The NX flag sets the key only if it doesn't already exist. The EX 600 sets a 10-minute TTL. This is a single atomic Redis command. No race between "check if exists" and "set the value."

If this command returns OK, your application proceeds to the PostgreSQL UPDATE. If it returns nil, another user beat you to it. You return a "seat unavailable" error without touching PostgreSQL at all. You've blocked a large fraction of your lock contention at the cache layer, before it reaches the database.

Why you still need the PostgreSQL UPDATE after setting the Redis lock: Redis is not durable by default. If your Redis instance restarts between the SET and the PostgreSQL UPDATE, the lock key is gone but the seat is not marked held in the database. A subsequent buyer would see it as available and could set a new Redis lock. Two users would end up with the same seat.

The PostgreSQL UPDATE is the authoritative write. Redis is the fast rejection layer that lives in front of it. Remove either one and you’ve created a gap between what users see and what the database actually holds.

Deep-Dive: Payment Failure and the Orphaned Hold Problem

The scenario nobody plans for: a user holds two seats in Section B, enters payment details, and then their card times out at the payment processor. The payment service returns after 30 seconds with an uncertain status: the processor doesn’t know if this charged or not.

Your hold is still active. The user is gone. The seats are locked for another 9 minutes.

This is the orphaned hold problem. It has two variants:

Variant 1: Timeout with no charge: The payment processor never received the request. Hold expires naturally. No action required. The TTL saves you.

Variant 2: Timeout with uncertain charge: The request may or may not have succeeded. This is where idempotency keys matter. Your payment attempt should carry a booking_reference that is unique to this hold attempt. The payment processor treats any retry with the same reference as a no-op if it already succeeded, or processes it if it did not. You retry once after the timeout, get a definitive answer, and proceed accordingly.

If the definitive answer is “payment failed,” you cancel the hold immediately in PostgreSQL (set status = 'available', clear held_by and held_until) and delete the Redis lock key. The seat is back in the pool within seconds, not 10 minutes.

If the user simply abandons (closes the browser, no retry), the hold expiry worker handles it. That worker runs on a 30-second interval, looking for:

UPDATE seats
SET
status = 'available',
held_by = NULL,
held_until = NULL,
version = version + 1
WHERE
status = 'held'
AND held_until < NOW()
RETURNING id, event_id;

The RETURNING clause gives you the freed seat IDs. Use them to invalidate the Redis availability cache for those specific seats. Don't wait for the 3-second TTL to expire naturally. Push an immediate invalidation so the seats reappear in the UI without delay.

Trade-Offs and Alternatives

Pessimistic locking (FOR UPDATE without SKIP LOCKED):

  • Works correctly for low concurrency or when the seat pool is very large relative to demand
  • Creates severe lock contention when hundreds of transactions compete for the same small pool
  • Avoid when: demand significantly exceeds supply, especially at peak moments

Optimistic locking (conditional UPDATE, no locks):

  • Zero lock contention. Every transaction runs without waiting
  • Higher retry rate when multiple users want the same specific seat
  • Best for: specific seat selection where the user chose by seat number, not by “give me anything available”

SKIP LOCKED + immediate UPDATE:

  • Eliminates contention for pool-based seat assignment
  • Returns an inconsistent view (intentional). May skip available seats if they’re mid-transaction
  • Best for: high-demand events where any available seat is acceptable

Redis-only locking:

  • Fast and low-latency
  • Not durable. Does not survive Redis restart without AOF persistence enabled
  • A single point of failure unless you’re running Redis Sentinel or Cluster
  • Suitable as a first-pass filter, not as the authoritative reservation store

PostgreSQL + Redis hybrid (what this design uses):

  • Redis handles fast rejection and availability reads
  • PostgreSQL handles durable state transitions
  • Complexity: two systems to maintain, two TTLs to keep in sync
  • Worth it above approximately 1,000 concurrent hold attempts per second

One Thing That Kills Every Booking System

You get the database locking right. You get the hold TTL right. You get the payment retry right.

Then the event goes on sale and your availability cache is returning stale data because your hold expiry worker can’t keep up with expiry volume. Users see seats marked as held that have already expired. They stop trying. Sales flatten before inventory is exhausted.

The hold expiry worker is the unsexy part of this system. It runs quietly in the background until it can’t. Monitor its lag: the gap between held_until and the actual time you process the expiry. If that gap exceeds 60 seconds during peak, you need more worker instances. If it exceeds 5 minutes, you have a backlog problem that availability reads alone can't mask.

Cache invalidation is only as good as the worker feeding it.

Closing

The first version of this system you build will probably use SELECT FOR UPDATE. It will work fine in staging, break at 500 concurrent users in production, and generate a postmortem about lock contention and thundering herds.

The second version will add Redis. The third version will properly separate the hold lifecycle from the payment path. Each iteration is a real system, and the lessons from each are worth learning.

The seat hold window is the hardest part to get right because it involves two systems, two clocks, and a user who might disappear at any point between click and confirmation. Build the expiry path before you build the happy path. Then test what happens when your payment service goes down for 3 minutes.

The seat map tells you what you built. The expiry worker tells you if it holds up.

Found this helpful?

If this article saved you time or solved a problem, consider supporting — it helps keep the writing going.

Originally published on Medium.

View on Medium
Building a Seat Reservation System for 50,000 Concurrent Buyers — Hafiq Iqmal — Hafiq Iqmal