Optimistic and pessimistic locking, and the lost-update race
The previous lesson ended on a warning: normal transaction isolation does not prevent the lost update — two requests reading a value, each changing it, the second overwriting the first. This is the most important concurrency bug in web applications: invisible in development (one request at a time), corrupting data in production (many at once). This lesson fixes it, two ways — optimistic and pessimistic locking — with optimistic locking verified against a running Dakiya.
The lost update, concretely
A Dakiya scenario: two staff members update the same parcel at the same time. Each request loads the parcel, changes a field, and saves:
Parcel p = repository.findById(id).orElseThrow(); // both requests read the SAME state
p.setStatus(newStatus); // each changes it
repository.save(p); // the second save overwrites the first
If the two overlap — both read, then both write — the second write is based on the value before the first
write, and silently clobbers it. One update is lost, with no error. On a parcel's status this is a
wrong state; on a balance or a stock count it is money or inventory vanishing. No transaction isolation
level below SERIALIZABLE stops this on its own, because each write is individually valid. You need
locking.
Optimistic locking: @Version
Optimistic locking assumes conflicts are rare, lets both transactions proceed, and detects a conflict at
write time. You add a @Version field to the entity:
@Entity
public class Hub {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private Long version; // Hibernate manages this — you never set it
}
Hibernate now includes the version in every update: UPDATE hub SET name = ?, version = 2 WHERE id = ? AND version = 1. If another transaction already bumped the version to 2, the WHERE version = 1 matches zero
rows, and Hibernate throws an OptimisticLockException (Spring wraps it as
ObjectOptimisticLockingFailureException). Verified against the running app: the hub started at
version 0; transaction A updated it (version → 1); transaction B, holding a stale copy at version 0,
tried to update and got the optimistic-lock exception — B's stale update was rejected, not silently lost.
That is the fix: the lost update becomes a detected conflict. You then handle it — typically by retrying
(reload the fresh state, reapply the change) or returning a 409 Conflict so the client retries. Optimistic
locking is the right default for web apps: it adds no locks (no contention when there is no conflict), costs
only a version column, and turns silent corruption into a visible, handleable conflict. Add @Version to any
entity that concurrent requests update.
Pessimistic locking: lock the row up front
Pessimistic locking takes the opposite stance: assume conflicts are likely, and lock the row when you read it, so no one else can touch it until you commit:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Parcel p where p.id = :id")
Parcel findByIdForUpdate(@Param("id") Long id);
This issues a SELECT ... FOR UPDATE, taking a database lock on the row. Any other transaction trying to
lock the same row waits until this one commits — so the two cannot interleave, and the lost update
cannot happen. It must run inside a transaction (the lock lives for the transaction's duration).
Optimistic vs pessimistic, when to use which:
- Optimistic (
@Version) — the default. Best when conflicts are rare (most web CRUD). No locking, no waiting; a conflict is detected and retried. Scales well. - Pessimistic (
SELECT ... FOR UPDATE) — when conflicts are frequent or a retry is unacceptable, or the operation is a short critical section (decrementing limited stock, allocating a unique slot). It guarantees no conflict but serialises access to the row, so it reduces concurrency and risks deadlocks — keep the locked section short.
The rule: optimistic by default (rare conflicts, no contention); pessimistic when contention is high or a conflict must be prevented outright — and keep pessimistic locks brief.
The database still needs its own guarantees: constraints
Locking coordinates updates; a related race is concurrent inserts creating a duplicate — two requests
both check "does this tracking code exist?", both see no, both insert. No application lock reliably prevents
this across transactions; the fix is a database constraint (UNIQUE), which the database enforces
regardless of timing — the second insert fails with a constraint violation. As in the Django course:
existence races are solved by constraints, not by check-then-act in code. So the full concurrency toolkit is:
@Version for the lost update on updates, pessimistic locks for high-contention critical sections, and
unique constraints for insert races — each for its situation.
Handling a detected conflict
Detecting a conflict is only useful if you handle it well. The common approaches:
- Retry — catch
ObjectOptimisticLockingFailureException, reload the current state, reapply the change, save again (Spring's@Retryable, or a manual loop with a bounded number of attempts). Right when the operation is naturally idempotent-ish (adjusting a status). - Return 409 Conflict — tell the client their update was based on stale data; they refetch and decide. Right when a human should confirm (they edited an old version of a record).
Never swallow the exception and carry on as if it succeeded — that reintroduces the lost update you just
detected. The principle tying the lesson together: concurrent writes to the same row need locking, not
just transactions; use optimistic @Version by default, pessimistic locks for hot rows, unique constraints
for insert races — and turn the detected conflict into a retry or a 409, never a silent success.
Check your work
The lost update. Two overlapping read-modify-write cycles: the second write overwrites the first, one
update lost silently. No isolation below SERIALIZABLE stops it — you need locking.
Optimistic (@Version). A version field; Hibernate updates WHERE version = ? and throws
OptimisticLockException/ObjectOptimisticLockingFailureException on a stale write. Verified: version 0 → 1
after A; B's stale update rejected. Default for web apps — no contention, conflict detected and retried.
Pessimistic (SELECT ... FOR UPDATE). @Lock(PESSIMISTIC_WRITE): lock the row on read; others wait.
For high contention or must-not-conflict critical sections; serialises access, risks deadlocks — keep short.
Constraints for insert races. Concurrent duplicate inserts are prevented by a UNIQUE constraint (the
DB enforces it regardless of timing), not application checks.
Handle the conflict. Retry (reload, reapply) or return 409 — never swallow the exception (that brings back the lost update).
Practice
- Reproduce the lost update: load a hub into two detached copies, update+save A (version→1), then update+
save B (stale version) and confirm
ObjectOptimisticLockingFailureException(reproduce the verified result). - Handle it by catching the exception, reloading, reapplying, and saving — a retry.
- Add a pessimistic
@Lock(PESSIMISTIC_WRITE)query and reason (or demonstrate with two sessions) about how a second transaction waits. - Decide, for three Dakiya operations (update parcel status, allocate a unique tracking code, decrement a limited daily quota), whether optimistic, pessimistic, or a unique constraint fits — and why.
- Add a
UNIQUEconstraint ontracking_codeand confirm two concurrent inserts of the same code cannot both succeed. - Swallow the optimistic-lock exception and carry on; observe the lost update returns — then handle it properly.
Official documentation
- Spring Data JPA — Locking (@Lock, @Version) — Optimistic and pessimistic locking.
- Jakarta Persistence — @Version and optimistic locking — The version field.
- Vlad Mihalcea — Optimistic vs pessimistic locking — When to use each, in depth.
Next: async work with @Async and thread pools.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship