
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:
- Read stock
- Reduce by 1
- 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
Productentity - One
productstable 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
| Approach | Behavior | Final stock in demo | Tradeoff |
|---|---|---|---|
| No locking | Lost update risk | 9 (wrong) | Simple but unsafe |
| Optimistic lock | Conflict detected at update time | 9 (one success, one fail) | Good for low/moderate conflict |
| Optimistic + Retry | Retry after conflict | 8 (correct) | Needs retry logic |
| Pessimistic lock | Row is locked, others wait | 8 (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.