System design Deep dive

Booking the last concert seat

A worked ticket-booking design with PostgreSQL row locks, expiring holds, payment retries, and the late confirmation that cannot take a seat back.

Share

Someone selects seat A14, enters their card details, and gets a bank verification screen. Their five-minute hold runs out while that screen is open. Another buyer reserves A14. Then the first payment succeeds.

A lock can settle who gets the seat. We still have to decide what to tell the person who has paid for it.

This is a worked design for numbered concert seats, using one authoritative PostgreSQL primary and an external payment provider. It is not a report of a ticketing system I operated. The policy here is deliberately strict: payment confirmation must claim the current, unexpired hold. A late success creates refund work, even if the provider accepted the payment earlier.

Give each performance its own seat row

Seat A14 exists before anyone tries to buy it. Its key includes the performance, because A14 on Friday and A14 on Saturday are different inventory.

CREATE TABLE seats (
  show_id bigint NOT NULL,
  seat_id text NOT NULL,
  hold_id uuid,
  expires_at timestamptz,
  booked boolean NOT NULL DEFAULT false,
  PRIMARY KEY (show_id, seat_id),
  CHECK ((hold_id IS NULL) = (expires_at IS NULL)),
  CHECK (NOT booked OR hold_id IS NOT NULL)
);

I would keep this row for the lifetime of the sale. Availability changes its fields. Deleting it on expiry would remove the row that competing requests need to lock.

Our invariant is small enough to state precisely: a performance's seat can have at most one confirmed booking. A single row makes that decision local. The primary key alone does not prevent overwriting its owner. Every path that changes ownership must follow the same transition rules, including administrative tools.

Bookings also need an order record, an authenticated buyer, price, currency, and payment references. Those records preserve history. An overwritten hold_id on the seat cannot be the only evidence that a previous customer tried to buy it.

Commit the hold before checkout starts

Reading available, then charging a card, then updating the seat leaves two callers free to act on the same read.

A short transaction can reserve A14 first. In the executable SQL example, acquire_seat locks the existing seat row, checks its current state, and records a new hold:

SELECT * INTO STRICT s FROM seats
  WHERE show_id = p_show AND seat_id = p_seat
  FOR UPDATE;

checked_at := clock_timestamp();

-- After checking whether this attempt already exists:
accepted := NOT s.booked
  AND (s.hold_id IS NULL OR s.expires_at <= checked_at);

An accepted attempt writes its ID and deadline, then commits. Only after that commit should the API return a successful reservation. The payment request runs outside this transaction.

PostgreSQL's default Read Committed isolation is sufficient for this particular decision over a known row. A competing SELECT FOR UPDATE waits for the first transaction, then receives the updated row if the first transaction commits. Our second caller checks that state and refuses the occupied seat.1

Checkout itself can last minutes. PostgreSQL row locks last until the transaction ends, so keeping the transaction open through checkout would keep a connection and lock occupied through card entry, redirects, and abandoned tabs.2 The committed hold carries that longer-lived reservation instead.

A database lock protects the few statements that change ownership. The five-minute hold is application state that survives the connection closing.

Expiry needs both time and identity

Five minutes is an example policy, not a recommended universal timeout. Whatever duration we choose, the server owns the deadline. The browser countdown is a display.

now() deserves care here. In PostgreSQL it means transaction-start time. A transaction can begin before a deadline, wait for a row lock, and obtain the lock after the deadline. Checking that old timestamp would give it time it no longer has.3

This example samples clock_timestamp() after obtaining the row lock. The booking decision uses that sample. It does not claim that commit or HTTP delivery occurs at the same instant. Keep the transaction short, and bound its lock wait.

Wall time can still move. Clock monitoring matters for whether a hold lasts as long as promised. Clock accuracy is not what prevents an old request from taking another buyer's seat: the current hold ID does that. I covered the broader server-time assumptions here.

Expiry also does not require a cleanup worker to run at the exact deadline. A new reservation can find an expired hold under the seat lock and replace it. Cleanup can tidy history later.

Every new reservation attempt gets a new ID. Retries reuse the existing attempt ID and never extend its deadline. After B replaces A, a delayed retry of A must return its expired state, not silently acquire a fresh hold. The SQL example keeps a separate hold_attempts record for that reason.

Move the payment past the deadline

A already holds A14 in this illustration. Change the order of the three events below. Each row represents a completed decision at the seat, not the time a browser sent a request. “Expiry” means A's deadline has passed, not that a cleanup job has run.

Reorder the arrivals

A starts with a hold on A14.

First to last

  1. A’s hold expiresA expired
  2. B requests A14B holds
  3. Confirm A’s paymentRefund due

Move confirmation above expiry.

stage Held by B
B keeps the hold. A’s payment needs a refund.

Putting A's confirmation first books the seat. Putting expiry first makes the same confirmation too late. If B then gets a hold, processing A's payment must leave B's ownership alone.

B can also arrive too early, receive “unavailable,” and leave. The seat may become available a moment later. Mutual exclusion doesn't promise that an unsuccessful request will wait around and inherit the next vacancy.

A successful payment still has to claim its hold

A provider callback should not run UPDATE seats SET booked = true WHERE seat_id = 'A14'. That code omits both the performance and the reservation that authorized the purchase.

Our handler first verifies the provider's signature and successful payment state, then checks the provider payment ID against the server's stored order mapping, amount, and currency. Browser redirects are not confirmation. Stripe also documents duplicate and out-of-order webhook delivery, so arrival order cannot be our payment state machine.4

After those checks, the booking transaction locks the seat and asks:

Have we already decided this payment's outcome?
  Return the stored outcome.

Does this payment belong to the current hold,
is that hold unexpired, and is the seat unbooked?
  Book it. Record payment outcome and ticket-delivery work.
Otherwise:
  Record payment outcome and refund work.

Commit those local writes together.

Compare the hold ID, not just the customer. One customer can abandon checkout and start another. Old payment work must not act on their newer reservation either.

I would record refund_pending in the same transaction that rejects the booking. Sending the refund inline and hoping the process survives leaves no reliable recovery path. An outbox worker handles the remote call later, with its own stable operation ID. A uniqueness constraint on the payment/action pair prevents duplicate callbacks from creating duplicate local refund jobs. The same arrangement applies to ticket delivery after a successful booking.

This is the database-to-network boundary again, but with a specific consequence: a refund request that timed out may already have succeeded. The worker must reconcile or retry that operation safely. refund_pending cannot become “refunded” merely because we queued it.

A timeout must not become a second purchase

Suppose the booking commits, but the HTTP response disappears. The customer presses the button again. A new order ID or payment object on every click would turn a transport retry into a fresh purchase attempt.

Keep a durable checkout attempt and expose its status to that customer. Use stable, operation-specific idempotency keys for remote writes. Creating a payment and requesting its refund are different operations, even though they concern the same order.

Stripe stores the first executed request's response for an idempotency key, including error responses. Its keys can be pruned after at least 24 hours, so a key is not a permanent purchase ledger.5 Keep the provider object ID and reconcile unknown outcomes rather than inventing a new key until one returns success.

Stripe recommends one PaymentIntent for a shopping cart or customer session and reusing it when checkout resumes.6 In this design I bind that object to the hold's checkout attempt. A newly acquired hold is a new attempt, not permission to reassign an old payment to a different seat.

Idempotency does not make a stale request valid. A duplicate callback for an already-booked payment can return its recorded success. A first callback for an expired hold still needs the late-payment decision.

Reduce late charges without hiding the intermediate states

Strict expiry is easy to explain to the database and unpleasant for the buyer at the bank verification screen. A production design needs a deliberate choice here.

One option is a bounded payment_pending period. Before starting payment, transition the hold into that state under the seat lock. Its deadline can be longer, but it cannot be infinite. Otherwise a stalled provider request can remove inventory from sale indefinitely. That is a different policy from the strict example above, and every reclaim and confirmation path must implement it.

Another option, for supported payment methods, is authorization followed by capture. Stripe supports reserving funds and capturing them later, with method-specific validity windows.7 After authorization, we could reserve the seat in a durable capture_pending state and request capture through recorded work.

That flow still has a recovery problem. If capture times out, don't release the seat just because the caller saw an error. First determine whether capture succeeded. If it definitively failed, release according to policy. If it succeeded, finish booking and ticket delivery. Manual capture reduces some charge-then-refund cases, but it adds states to operate.

Neither variation belongs in a hidden retry loop. Customers need an honest pending status, and support needs a way to find orders stuck between payment and ticket delivery.

More traffic doesn't remove the contested seat

Concert listings, images, and approximate availability can tolerate caching. A stale seat map is inconvenient, but it need not oversell anything if the reservation path checks the authoritative row again.

Thousands of requests for A14 still converge on one ownership decision. Adding application servers doesn't make that decision parallel. I would put admission limits ahead of the database, cap connection-pool waits, and return a retryable busy response before requests accumulate without bound. A waiting room can control admission. It cannot prove ownership.

SKIP LOCKED is useful for workers taking interchangeable jobs. PostgreSQL warns that it gives an inconsistent view.8 Skipping a locked A14 does not tell a buyer whether A14 is sold, held, or briefly being updated. For a specific requested seat, distinguish contention from unavailability.

Group booking changes the transaction. Lock every requested seat in a consistent order, validate the entire set, and commit all holds together or none. Ordering locks reduces deadlock risk.2 General admission is a different inventory model again: an atomic bounded decrement or individually claimable units, not a row for a numbered seat that doesn't exist.

Replication is part of the guarantee too. PostgreSQL documents that asynchronous failover can lose acknowledged transactions.9 If a promoted standby has forgotten a booking, the row-level design cannot restore it by itself. The durability and failover policy must match the promise, and recovery must prevent two primaries from independently selling the same inventory.

What I would test before opening sales

Concurrency tests need separate database connections. A sequential unit test that calls the reservation function twice never exercises the lock wait.

I ran the downloadable SQL in an isolated PostgreSQL 18.1 instance. Two concurrent callers produced one hold. The checks also covered retrying without extending a deadline, replacing an expired hold, processing its late payment, duplicate callbacks, and a second payment object for an already-booked hold. All six event orders in the illustration matched the database outcomes.

You can check the lock yourself in a fresh disposable database. Load the SQL file, then open two psql sessions against that database. Start this transaction in the first session and leave it open:

BEGIN;
SELECT acquire_seat(
  42, 'A14', '00000000-0000-4000-8000-000000000001'
); -- held, but not committed yet

Run the competing request in the second session:

SELECT acquire_seat(
  42, 'A14', '00000000-0000-4000-8000-000000000002'
);

It waits. Commit the first session while the hold still has time left. The second returns unavailable if it obtains the lock before that deadline. On a fresh run, rolling back the first session instead lets the second return held. The outcome depends on the committed reservation, not which browser first displayed A14 as available.

I also forced a confirmation to wait on another connection's seat lock, expired the hold through that connection, then committed. The waiting confirmation returned refund_pending, even though its transaction had started before expiry.

Those checks validate the local example, not a payment integration or a production failover plan. The fixture deliberately omits authentication, price validation, provider signature handling, and the worker that actually issues tickets or refunds. It assumes those validations precede its payment function.

Before release, I would also interrupt the real integration after each external request, after each local commit, and before each acknowledgement. Tests should leave enough durable state to answer three questions: who currently owns A14, what happened to the money, and which action still needs to finish?

A seat lock answers the first question for a short transaction. The rest of checkout needs records that remain useful after that transaction, its connection, and the customer's browser are gone.

References checked on 21 September 2026.

Footnotes

  1. PostgreSQL 18, Read Committed isolation. ↩

  2. PostgreSQL 18, row-level locks and deadlocks. ↩ ↩2

  3. PostgreSQL 18, current date and time functions. ↩

  4. Stripe, webhook ordering, duplicate events, and signature verification. ↩

  5. Stripe API, idempotent requests and retention. ↩

  6. Stripe, the Payment Intents API. ↩

  7. Stripe, separate authorization and capture. ↩

  8. PostgreSQL 18, SELECT locking clauses. ↩

  9. PostgreSQL 18, standby servers and replication trade-offs. ↩

Share

Comments