← ~/blog
2026-09-14

Preventing Double Bookings with PostgreSQL Transactions

Backend EngineeringDatabase TransactionsConcurrencyPostgreSQLSystem Design

When building FlashSeat, my event reservation application, I encountered an interesting backend engineering problem:

What happens when two users try to reserve the last available ticket at exactly the same time?

At first, the reservation process seemed straightforward. The backend checks the remaining inventory, creates a reservation, and updates the number of reserved tickets.

However, this approach can fail when multiple requests execute concurrently.

Understanding the Race Condition

Imagine an event with a capacity of 100 tickets and 99 tickets already reserved.

Two users send reservation requests at almost the same time.

Both requests might read the same database state and conclude that one ticket is still available.

If the application processes both requests without proper concurrency control, it could accept both reservations and oversell the event.

This is an example of a race condition.

The problem is not necessarily incorrect business logic. Instead, it comes from multiple operations interacting in an unexpected order.

Using Database Transactions

I approached this problem using PostgreSQL transactions and row-level locking.

The reservation process follows these steps:

  1. Start a database transaction.
  2. Lock the relevant event row using SELECT … FOR UPDATE.
  3. Check whether enough tickets are available.
  4. Insert the reservation record.
  5. Update the reserved ticket count.
  6. Commit the transaction.

The row lock prevents competing transactions from modifying the same event row simultaneously while the reservation is being processed.

A second transaction attempting to lock that row must wait until the first transaction releases its lock.

This allows the system to check availability against a consistent state before accepting another reservation.

I also use database constraints as an additional layer of protection against invalid inventory values.

Why Application-Level Checks Are Not Enough

One of the biggest lessons from this implementation was that checking a condition in application code does not automatically make an operation safe.

A request can read valid information that becomes outdated before the next database operation.

Correctness sometimes requires the database itself to enforce coordination.

This is especially important for systems involving limited resources, such as ticket reservations, inventory management, and financial transactions.

What I Would Improve Next

Row-level locking addresses an important concurrency problem, but it does not solve every reservation-related challenge.

Future improvements could include idempotency keys to handle repeated requests, additional concurrency testing, and more comprehensive failure recovery.

Building FlashSeat has made me increasingly interested in database internals, transaction isolation, and distributed systems.

It also changed how I think about backend engineering.

A reliable backend is not just one that returns the correct response under normal conditions. It must also behave correctly when multiple requests arrive simultaneously.

Project: https://github.com/Takamichi-Hori/Flashseat