Imagine Cyber Monday. Thousands of users competing for the last available seat on a discounted flight. Without a robust strategy, your application could crash or—worse—cause overbooking!

In this post, we’ll explain 4 strategies to handle concurrency in seat reservation systems built with Java 21, Spring Boot 3.5.x, and a PostgreSQL database.

This setup works perfectly for applications where concurrency is NOT an issue.

@Override
@Transactional
public Seat assignSeat(Seat seat) {
   LOGGER.info("Trying to assign seat: {}", seat);
    
    //1 . We perform a lookup to validate that the seat we want to reserve exists
   Seat seatFound = seatRepository.findById(seat.id()).orElseThrow(() -> {
       LOGGER.info("Seat with id {} was not found", seat.id());
       return new NoSuchElementException(String.format("Seat with id %d was not found", seat.id()));
   });
    
// 2. If the seat exists, we check whether it is already assigned to a user.
   if(Objects.nonNull(seatFound.userId())){
    
// 3. If the seat is assigned to the same user, we return the booking confirmation (Idempotency)        
if(Objects.equals(seatFound.userId(), seat.userId())){
           LOGGER.info("Seat with id {} is already assigned to same user {}", seat.id(), seat.userId());
           return seatFound;
       }
// 3. If the seat is assigned to another user, we throw an exception indicating that the seat is no longer available


       LOGGER.info("Seat with id {} is already assigned to user {}", seat.id(), seat.userId());
       throw new SeatAlreadyAssignedException(String.format("Seat with id %d is already assigned", seat.id()));
   }
// 4. If the seat EXISTS and is NOT assigned, we can create the requested reservation.
   return seatRepository.assignSeatToUser(new Seat(seat.id(), seat.userId()));
}

The problem arises when multiple users try to book the same seat at the same time.

Without proper control, two users could pass the initial checks (since the seat hasn't yet been marked as occupied by the other) and both would end up receiving a booking confirmation, even though the seat is actually reserved for only one of them in the database.

Let's look at an example:

Using the tool JMeter, we simulated two concurrent users trying to book the same seat, and the result we got was that both users received a reservation confirmation for seat number 1 — however, in the database we can see that only one user has been assigned the seat.

seat_id
[PK] bigint
user_id
integrer
1 1 1

Can we imagine the problem we’ll face when user 2 arrives at the airport with their confirmed reservation, only to be told that the booking doesn’t exist and that another user has that seat?

Next, we’ll explain a few solutions to address this issue.

1 Method-level synchronization using synchronized

The most straightforward way to handle concurrency in Java is by using the synchronized keyword.

When applied to a method, it ensures that only one thread can execute that method on a particular instance of the object at a time.

@Override
@Transactional
public synchronized Seat assignSeat(Seat seat) {
// SAME CODE BLOCK AS IN THE ORIGINAL EXAMPLE
}

Let’s look at an example:

We run the same execution of 2 concurrent users using JMeter, and this time, while User 1 receives an error response, User 2 receives the confirmation of their booking.

seat_id
[PK] bigint
user_id
integrer
1 1 2

Advantages:

Disadvantages:

Considerations for distributed environments

Among the options available to implement distributed locking, we can choose between Apache ZooKeeper and Hazelcast.

However, integrating ZooKeeper or Hazelcast adds an additional infrastructure component and may increase the complexity and cost of maintaining the system.

This setup doesn't make sense when the following strategies we'll explore can already solve the initial problem.

2 Pessimistic Locking

Pessimistic locking is a database-level strategy that explicitly locks a record (or row) at the time it is read for modification.

This prevents other threads or transactions from accessing that record until the lock is released.

Code example (JPA/Hibernate)

The assignSeat(Seat seat) method continues with its original logic:

@Override
@Transactional
public Seat assignSeat(Seat seat) {
// SAME CODE BLOCK AS IN THE ORIGINAL EXAMPLE
}

Meanwhile, in our data access layer interface (Repository), which implements the JpaRepository interface, we add a method that, at the time of fetching the domain entity, applies the lock.

@Repository
public interface SeatJPaRepository extends JpaRepository<SeatEntity, Long> {
/** * Searches for a seat by its number and applies a pessimistic (WRITE) lock on it.  
    * This prevents other transactions from reading or modifying the record until the current transaction is completed.  
    * @param seatId The seat number  
    * @return An Optional containing the found seat or an empty Optional if the seat does not exist  
    */
   @Lock(LockModeType.PESSIMISTIC_WRITE)
   Optional<SeatEntity> findWithLockingBySeatId(Long seatId);
}

Let's look at an example. We run the same test with 2 concurrent users using JMeter. In the logs, we can see that two different threads try to assign the same seat at the exact same moment:

2025-07-02 09:36:08.250 [http-nio-8080-exec-1] INFO  c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=2]


2025-07-02 09:36:08.250 [http-nio-8080-exec-2] INFO  c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=1]

We can also observe that both threads execute the same database query at the exact same moment:

2025-07-02 09:36:08.338 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL - 
    /* <criteria> */ select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=? for no key update


2025-07-02 09:36:08.338 [http-nio-8080-exec-1] DEBUG org.hibernate.SQL - 
    /* <criteria> */ select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=? for no key update

But only thread 2 (user 1) is the one that performs the record modification:

2025-07-02 09:36:08.419 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL - 
    /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat  set user_id=? where seat_id=?

In the database, we can see that only user 1 has the seat assigned.

seat_id
[PK] bigint
user_id
integrer
1 1 1

Meanwhile, user 2 receives a response indicating that they could NOT confirm their seat. If we check the log:

2025-07-02 09:36:08.431 [http-nio-8080-exec-1] INFO  c.p.d.c.s.SeatServiceImpl - Seat with id 1 is already assigned to another user 1

Advantages

Disadvantages

3 Optimistic Locking

Optimistic locking assumes that collisions are rare. Instead of locking the record, it uses a "versioning" mechanism (usually a numeric or timestamp column) in the table.

When a transaction reads a record, it also reads its version number, and upon attempting to update the record, it checks if the version number in the database matches the one originally read.

If they don’t match, it means another transaction modified the record, and the current operation fails.

Code Example (JPA/Hibernate)

The method assignSeat(Seat seat) remains with its original logic.

@Override
@Transactional
public Seat assignSeat(Seat seat) {
// SAME CODE BLOCK AS IN THE ORIGINAL EXAMPLE
}

No changes are made to the original logic in any of the services. The only change applied is at the domain entity definition level. We need to modify the SeatEntity by adding the @Version column:

@Entity
@Table(name = "seat")
public class SeatEntity implements Serializable {
   @Id
   @Column(name = "seat_id", nullable = false)
   private Long seatId;


   @Column(name = "user_id")
   private Integer userId;


   @Version  // Esta columna es manejada automáticamente por Hibernate


   private Integer version;
}

Let's look at an example. We run the same test with 2 concurrent users using JMeter. In the logs, we can see that two different threads try to assign the same seat at the exact same moment:

2025-07-02 10:42:22.491 [http-nio-8080-exec-1] INFO  c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=2]


2025-07-02 10:42:22.491 [http-nio-8080-exec-2] INFO  c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=1]

We can also observe that both threads make the same query to the database at the exact same moment:

2025-07-02T10:42:22.533+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-2] org.hibernate.SQL : select se1_0.seat_id, se1_0.user_id, se1_0.version from seat se1_0 where se1_0.seat_id=? 


2025-07-02T10:42:22.533+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-1] org.hibernate.SQL : select se1_0.seat_id, se1_0.user_id, se1_0.version from seat se1_0 where se1_0.seat_id=?

Then, we can see that both threads are trying to update at the same time:

2025-07-02T10:42:22.590+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-2] org.hibernate.SQL : /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=?, version=? where seat_id=? and version=? 


2025-07-02T10:42:22.590+02:00 DEBUG 27632 --- [concurrency] [nio-8080-exec-1] org.hibernate.SQL : /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=?, version=? where seat_id=? and version=?

At this point, we can see that when updating the record, the where clause uses the version field to ensure it is modifying the same version of the record that was previously read. User 1 receives the confirmation, and if we check the database, we can confirm that user 1 has been assigned seat 1 and the record version has changed from 0 to 1.

seat_id
[PK] bigint
user_id
integrer
version
integrer
1 1 1 1

What happened to user 2 who also tried to perform the update? They received a non-confirmation message. And to understand why the update failed, let's take a look at the logs:

2025-07-02T10:42:22.619+02:00 ERROR 27632 --- [concurrency] [nio-8080-exec-1] c.p.d.c.m.e.GlobalExceptionHandler       : Received ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.paradigmadigital.demo.concurrency.models.entities.SeatEntity#2]

This happens because user 2's update was attempted after user 1's update, who had already changed the record’s version from 0 to 1. So, when user 2 tried to perform the update with the condition where seat_id=1 and version=0, that condition no longer held true and the application threw an exception.

Advantages

Disadvantages

4 Transaction Isolation Levels

The method responsible for seat reservation is executed within a transaction (as in all the previous solutions), but in this case, we change the isolation level we want to use.

The @Transactional annotation is configured by default with the DEFAULT isolation level. This means that when Spring creates a new transaction, the isolation level used will be the one configured by default in your database.

Isolation levels are used to prevent concurrency side effects within a transaction.

Considerations for configuring isolation levels: since this post focuses on solving the problem programmatically, it's important to note that isolation level configuration can also be set at the database level.

Code Example (JPA/Hibernate)

The assignSeat(Seat seat) method remains unchanged in terms of logic. The only change applied is the isolation level configuration within the @Transactional annotation.

We have two options:

@Override
@Transactional(isolation = Isolation.REPEATABLE_READ)
// Does not allow simultaneous access to a row.
public Seat assignSeat(Seat seat) {
// SAME CODE BLOCK AS IN THE ORIGINAL EXAMPLE
}
@Override
@Transactional(isolation = Isolation.SERIALIZABLE)
// It is the highest level of isolation. It prevents all concurrency side effects but may lead to the lowest rate of concurrent access because it executes concurrent calls sequentially.
public Seat assignSeat(Seat seat) {
// AME CODE BLOCK AS IN THE ORIGINAL EXAMPLE
}

Let's take a look at an example. We run the same test again with 2 concurrent users using JMeter. In the logs, we can see that two different threads are attempting to assign the same seat at the exact same time:

2025-07-03 10:05:24.827 [http-nio-8080-exec-1] INFO  c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=2]


2025-07-03 10:05:24.826 [http-nio-8080-exec-2] INFO  c.p.d.c.s.SeatServiceImpl - Trying to assign seat: Seat[id=1, userId=1]

We can also observe that both threads execute the same query to the database at the exact same moment:

2025-07-03 10:05:24.861 [http-nio-8080-exec-1] DEBUG org.hibernate.SQL - select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=?
 
2025-07-03 10:05:24.861 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL - select se1_0.seat_id, se1_0.user_id from seat se1_0 where se1_0.seat_id=?

Then we see that both threads are attempting to update at the same time:

2025-07-03 10:05:24.921 [http-nio-8080-exec-1] DEBUG org.hibernate.SQL - /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=? 


2025-07-03 10:05:24.921 [http-nio-8080-exec-2] DEBUG org.hibernate.SQL - /* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?

While we see that User 1 receives the confirmation of their seat assignment, we verify it in the database:

seat_id
[PK] bigint
user_id
integrer
1 1 1

User 2 receives a message stating that they could NOT confirm their reservation:

2025-07-03 10:05:24.947 [http-nio-8080-exec-1] ERROR c.p.d.c.m.e.GlobalExceptionHandler - Received RuntimeException: could not execute statement [ERROR: could not serialize access due to concurrent update] [/* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?]; SQL [/* update for com.paradigmadigital.demo.concurrency.models.entities.SeatEntity */update seat set user_id=? where seat_id=?]

It’s worth noting that the result is the same regardless of which isolation level is used.

Isolation.REPEATABLE_READ

Advantages

Disadvantages

Isolation.SERIALIZABLE

Advantages

Disadvantages

Conclusion

The choice of concurrency management strategy heavily depends on the characteristics of your application, the database engine, the number of concurrent users, and throughput.

Using synchronized is ideal for internal concurrency scenarios within a single application instance, where performance impact is acceptable. It’s the simplest solution, but also the least scalable and not suitable for distributed environments.

For such environments, solutions like Apache ZooKeeper or Hazelcast can offer distributed locks, though at the cost of increased infrastructure complexity and expense.

Pessimistic locking is the safest option when data consistency is critical and there is high contention for a specific resource. Locks must be managed carefully to avoid deadlocks.

Optimistic locking is one of the best solutions when you want a balance between integrity and performance. It minimizes database overhead and allows most transactions to complete without waiting.

Transaction isolation levels are recommended in scenarios where you prefer to delegate concurrency control to the database, adjusting the level of strictness according to your needs. This option provides fine-grained control, allowing specific configuration per operation.

Tell us what you think.

Comments are moderated and will only be visible if they add to the discussion in a constructive way. If you disagree with a point, please, be polite.

Subscribe