Ritesh Panigrahi
Optimistic vs Pessimistic Locking in Spring Boot (With Practical Example)

March 21, 2026

Optimistic vs Pessimistic Locking in Spring Boot (With Practical Example)

In the previous article, we understood how databases use locks and MVCC internally to maintain isolation.

But one practical problem still remains:

What happens when two users try to update the same data at the same time?

And more importantly,

How do we handle this in our application?

In this article, we will understand this using a simple Spring Boot demo:

  • what happens when we do no locking
  • how optimistic locking works
  • how pessimistic locking works
  • when to use which one

The Problem

Let us take a simple use case.

We have one product:

  • Product: iPhone
  • Stock: 10

Now two users try to buy at the same time.

Both transactions do:

  1. Read stock
  2. Reduce by 1
  3. Update stock

Expected final stock should be 8.

But if concurrency is not handled, final stock can become wrong.


Demo Setup

The same setup is used in all scenarios:

  • One Product entity
  • One products table row
  • Two threads running at the same time
  • Both reduce stock by 1

Endpoints:

  • /demo/no-lock
  • /demo/optimistic-lock
  • /demo/optimistic-lock-retry
  • /demo/pessimistic-lock

Demo source code:

In the entity, we have a version column for optimistic locking:

@Entity
@Table(name = "products")
public class Product {

    @Id
    private Long id;

    private String name;
    private int stock;

    @Version
    private Long version;
}

Phase 1: No Locking (Lost Update)

First, let us see what happens without locking.

Code

Worker method:

@Transactional
public void reduceStockWithoutLock(Long productId) {
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));

    int currentStock = product.getStock();
    int newStock = currentStock - 1;

    sleepForDemo();

    productRepository.updateStockWithoutVersionCheck(productId, newStock);
}

Repository update query:

@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update Product p set p.stock = :stock where p.id = :id")
int updateStockWithoutVersionCheck(@Param("id") Long id, @Param("stock") int stock);

This query is important because it bypasses version check and makes the lost update visible.

What happens

  • Thread A reads 10
  • Thread B reads 10
  • Thread A writes 9
  • Thread B writes 9

Final stock becomes 9 (wrong). It should be 8.

This is the lost update problem.

Output

{
  "scenario": "no-lock",
    "initialStock": 10,
    "finalStock": 9,
    "finalVersion": 0,
    "events": [
        "Two threads are started and run in parallel",
        "no_lock-thread-2 started for NO_LOCK",
        "no_lock-thread-1 started for NO_LOCK",
        "Expected stock after 2 decrements = 8",
        "Actual final stock = 9 (lost update)"
    ]
}

Phase 2: Pessimistic Locking

Idea

Assume conflict will happen, so lock row early.

Repository lock method

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);

Worker method

@Transactional
public void reduceStockWithPessimisticLock(Long productId) {
    Product product = productRepository.findByIdForUpdate(productId)
            .orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));

    int newStock = product.getStock() - 1;
    sleepForDemo();

    product.setStock(newStock);
    productRepository.saveAndFlush(product);
}

What happens

  • Thread A acquires row lock
  • Thread B waits
  • A updates and commits
  • B continues, reads latest stock, updates
T1 acquires lock
T2 tries → waits

T1 updates → 9
T1 commits

T2 continues
T2 reads → 9
T2 updates → 8

Final stock becomes 8 (correct).

Output

{
  "scenario": "pessimistic-lock",
  "initialStock": 10,
  "finalStock": 8,
  "finalVersion": 2,
  "events": [
    "Two threads are started and run in parallel",
    "pessimistic-thread-2 started for PESSIMISTIC",
    "pessimistic-thread-1 started for PESSIMISTIC",
    "pessimistic-thread-2 is trying to acquire the row lock",
    "pessimistic-thread-1 is trying to acquire the row lock",
    "pessimistic-thread-2 acquired the row lock and read stock=10",
    "pessimistic-thread-2 committed after holding the row lock",
    "pessimistic-thread-1 acquired the row lock and read stock=9",
    "pessimistic-thread-1 committed after holding the row lock",
    "The second transaction could continue only after the first transaction released the lock",
    "Final stock = 8"
  ]
}

Tradeoff

  • Correct and safe
  • But other transactions may wait (blocking)

Phase 3: Optimistic Locking

Idea

Assume conflict is rare. Do not block early. Detect conflict at update time.

Worker method

@Transactional
public void reduceStockWithOptimisticLock(Long productId) {
    Product product = productRepository.findById(productId)
            .orElseThrow(() -> new EntityNotFoundException("Product not found: " + productId));

    int newStock = product.getStock() - 1;
    sleepForDemo();

    product.setStock(newStock);
    productRepository.saveAndFlush(product);
}

Because of @Version, Hibernate performs update with version check.

Conceptually it behaves like:

update products
set stock = ?, version = ?
where id = ? and version = ?

If version changed already, second update fails with optimistic locking exception.

What happens

  • Both threads read same version
  • One commits first
  • Second fails due to stale version
T1 reads → stock=10, version=1
T2 reads → stock=10, version=1

T1 updates → success (version becomes 2)
T2 updates → fails ❌

Final stock becomes 9 (only one update succeeded).

Output

{
  "scenario": "optimistic-lock",
  "initialStock": 10,
  "finalStock": 9,
  "finalVersion": 1,
  "events": [
    "Two threads are started and run in parallel",
    "optimistic-thread-1 started for OPTIMISTIC",
    "optimistic-thread-2 started for OPTIMISTIC",
    "optimistic-thread-1 committed successfully",
    "optimistic-thread-2 failed with optimistic locking conflict",
    "One update succeeded and one failed because the version changed",
    "Final stock = 9"
  ]
}

Phase 4: Optimistic Locking with Retry

In real applications, we usually retry on optimistic conflicts.

Retry loop from demo

private void runOptimisticRetryLoop(Long productId, int maxAttempts, List<String> events) {
    for (int attempt = 1; attempt <= maxAttempts; attempt++) {
        try {
            productWorkerService.reduceStockWithOptimisticLock(productId);
            return;
        } catch (OptimisticLockingFailureException ex) {
            if (attempt == maxAttempts) {
                throw ex;
            }
            sleepBeforeRetry();
        }
    }
}

What happens

  • One thread fails first attempt
  • It retries
  • On retry, it reads latest version and succeeds
T1 updates → success
T2 fails → retry
T2 reads new value → 9
T2 updates → 8

Final stock becomes 8 (correct).

Output

{
  "scenario": "optimistic-lock-retry",
  "initialStock": 10,
  "finalStock": 8,
  "finalVersion": 2,
  "events": [
    "Two threads are started and run in parallel",
    "optimistic_retry-thread-1 started for OPTIMISTIC_RETRY",
    "optimistic_retry-thread-1 attempt 1 started",
    "optimistic_retry-thread-2 started for OPTIMISTIC_RETRY",
    "optimistic_retry-thread-2 attempt 1 started",
    "optimistic_retry-thread-2 finished after retry logic",
    "optimistic_retry-thread-1 attempt 1 hit version conflict",
    "optimistic_retry-thread-1 attempt 2 started",
    "optimistic_retry-thread-1 finished after retry logic",
    "Retry lets the failed transaction reload the latest version and try again",
    "Final stock = 8"
  ]
}

Why @Transactional is Important

All worker methods are transactional.

This is required because:

  • optimistic version checks happen in transaction context
  • pessimistic locks must be held until commit/rollback

Comparison

ApproachBehaviorFinal stock in demoTradeoff
No lockingLost update risk9 (wrong)Simple but unsafe
Optimistic lockConflict detected at update time9 (one success, one fail)Good for low/moderate conflict
Optimistic + RetryRetry after conflict8 (correct)Needs retry logic
Pessimistic lockRow is locked, others wait8 (correct)More waiting / lower throughput

When to Use What

Use Optimistic Locking when:

  • conflicts are occasional
  • reads are high
  • you want better throughput
  • retry is acceptable

Use Pessimistic Locking when:

  • conflicts are frequent
  • incorrect update is costly
  • strict serialization is needed

Do not use no locking for shared critical updates.


Important Note

In this demo we used H2 for simplicity.

In production, behavior details can vary by database engine (PostgreSQL, MySQL, Oracle, etc.), but core idea remains same:

  • no lock => lost update risk
  • optimistic => detect stale update with version
  • pessimistic => lock row and serialize updates

Conclusion

In this article, we saw how the same problem behaves with:

  • no locking
  • pessimistic locking
  • optimistic locking

Without any locking, we can end up with incorrect data.

Pessimistic locking solves this by blocking other transactions, but it can impact performance.

Optimistic locking allows better concurrency, but we need to handle conflicts properly, usually with retry.

👉 There is no one best approach.

It depends on:

  • how often conflicts happen
  • how critical the data is
  • how much performance matters

Understanding this helps us choose the right approach based on the use case.

SpringbootJavaDatabasesConcurrencySystem Design