Imagine a scenario where multiple users try to purchase the same product at the same time. There is only one item in stock, yet two orders go through. This is known as the “Lost Update problem” — a classic concurrency issue.

This article explains how to solve this problem using optimistic locking with JPA’s @Version annotation, complete with implementation code.

What Is the Lost Update Problem?

Consider a case where users A and B simultaneously try to purchase a product with 10 units in stock.

  1. User A reads the stock (10 units)
  2. User B also reads the stock (10 units)
  3. User A updates the stock by 3 units (10 - 3 = 7 units)
  4. User B updates the stock by 5 units (10 - 5 = 5 units) ← overwrites A’s update!

The correct result should be 7 - 5 = 2 units, but it ends up as 5. The same issue occurs in reservation systems when checking seat availability.

There are two approaches to solving this. Pessimistic locking (SELECT FOR UPDATE) locks rows at the database level, while optimistic locking detects conflicts after the fact using a version number. For web applications where conflicts are infrequent, optimistic locking is the better fit due to its lower overhead.

How @Version Works

When JPA updates an entity that has a @Version field, it automatically appends a WHERE version = ? condition to the standard UPDATE statement.

UPDATE product SET stock = ?, version = 2 WHERE id = ? AND version = 1

If the version matches, the update succeeds and the version is incremented. If it does not match, zero rows are updated and JPA throws an OptimisticLockException. It is simple, but reliably detects conflicts.

Adding @Version to an Entity

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int stock;

    @Version
    private Long version; // Managed automatically by JPA — do not update manually

    // getter/setter...
}

Types supported for the @Version field include int, Integer, long, Long, and Timestamp. Long is sufficient in most cases. Timestamp carries the risk of missing updates that occur within the same millisecond, so an integer type is recommended.

When used with Spring Data JPA’s JpaRepository, no additional configuration is required. Simply calling save() activates version management automatically.

Note that including the version number in API requests and responses allows the frontend to communicate to the server which version it based its update on.

Observing OptimisticLockException in Action

When going through Spring Data JPA, the exception thrown is org.springframework.orm.ObjectOptimisticLockingFailureException. This is Spring’s wrapper around jakarta.persistence.OptimisticLockException. In practice, catching the Spring-side exception is all you need.

Loading the same entity in two separate transactions and committing one after the other will trigger the exception. To reproduce this yourself, use the test code shown later in this article.

Catching the Exception and Returning HTTP 409

An optimistic lock exception is an expected business exception. Rather than letting it surface as a 500 error, map it to a 409 Conflict using @ControllerAdvice.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    public ResponseEntity<ErrorResponse> handleOptimisticLock(
            ObjectOptimisticLockingFailureException ex) {
        ErrorResponse body = new ErrorResponse(
            "CONFLICT",
            "Another user has updated the same data. Please fetch the latest data and try again."
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
    }
}

Converting to a business exception in the service layer is also a valid pattern.

@Transactional
public void purchaseProduct(Long productId, int quantity) {
    try {
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStock(product.getStock() - quantity);
    } catch (ObjectOptimisticLockingFailureException e) {
        throw new StockConflictException("Stock information has changed. Please try again.");
    }
}

Including a message in the error response that prompts the user to fetch the latest data and retry will improve the user experience.

For error handling in general, see Returning Unified Error Responses from a Spring Boot REST API.

Retry Strategies: @Retryable and Manual Loops

Here are two patterns for automatically retrying when a conflict occurs.

Using Spring Retry

Add the dependency to pom.xml and annotate your main class or configuration class with @EnableRetry.

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
@Retryable(
    retryFor = ObjectOptimisticLockingFailureException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100)
)
@Transactional
public void purchaseProduct(Long productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.setStock(product.getStock() - quantity);
}

@Retryable is declarative and straightforward, but lacks flexibility when retry conditions need to incorporate business logic. In those cases, a manual retry with a while loop gives you more control. If the retry limit is exceeded, consider notifying the user with an error message or emitting an alert log.

Writing Concurrency Tests with ExecutorService

Use tests to verify that optimistic locking is working correctly. Using CountDownLatch to start all threads simultaneously ensures that conflicts are reliably reproduced.

@SpringBootTest
class ProductServiceConcurrentTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void concurrentUpdateShouldThrowOptimisticLockExceptionForOneSide() throws InterruptedException {
        Product product = new Product("Test Product", 10);
        productRepository.save(product);
        Long productId = product.getId();

        ExecutorService executor = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch(1);
        AtomicInteger conflictCount = new AtomicInteger(0);

        for (int i = 0; i < 2; i++) {
            executor.submit(() -> {
                try {
                    latch.await();
                    productService.purchaseProduct(productId, 1);
                } catch (ObjectOptimisticLockingFailureException e) {
                    conflictCount.incrementAndGet();
                } catch (InterruptedException ignored) {}
            });
        }

        latch.countDown();
        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);

        assertThat(conflictCount.get()).isGreaterThan(0);
    }
}

Using an H2 in-memory database keeps the tests lightweight with no external dependencies.

When Optimistic Locking Is Not a Good Fit

Optimistic locking is not suited to every situation.

In high-contention scenarios — such as flash sales or tickets for popular events selling out instantly — the overhead of repeated conflicts and retries accumulates and degrades performance. Long-running batch updates also carry a higher risk of being overwritten by another transaction partway through.

For high-contention or long-running operations, pessimistic locking (@Lock(LockModeType.PESSIMISTIC_WRITE)) is the better choice. For details on transaction management, see Understanding Transaction Management with @Transactional in Spring Boot.

Most web applications follow a “read-heavy, write-light” pattern, so optimistic locking is a sufficient default strategy.

Summary

Simply adding a @Version field causes JPA to automatically append a version condition to UPDATE statements, preventing Lost Updates.

  • Add @Version Long version to the entity
  • Map ObjectOptimisticLockingFailureException to HTTP 409 using @ControllerAdvice
  • Use @Retryable or a manual loop for automatic retries
  • Write concurrency tests with ExecutorService + CountDownLatch to verify behavior

For JPA entity relationship mapping, see How to Map JPA Entity Relationships in Spring Boot. For performance optimization, see How to Improve Spring Boot Data JPA Performance.