Optimistic Locking in Hibernate — @Version, Retry Strategies, and Conflict Resolution

by Eric Hanson, Backend Developer at Clean Systems Consulting

The lost update problem

Without concurrency control, two concurrent requests that read and modify the same entity produce a lost update:

Thread A: reads Order(id=1, status=PENDING, total=100)
Thread B: reads Order(id=1, status=PENDING, total=100)
Thread A: updates status to PROCESSING, commits → Order(status=PROCESSING, total=100)
Thread B: updates total to 150, commits → Order(status=PENDING, total=150)
                                          ^ Thread A's update is lost

Thread B loaded the entity before Thread A committed. Thread B's update overwrites Thread A's changes. The final state (PENDING, 150) reflects neither thread's intent — status=PROCESSING from Thread A is silently discarded.

Optimistic locking prevents this by detecting when the row has changed between the read and the write.

@Version — the mechanism

Adding @Version to an entity adds a version column. Hibernate includes the version value in every UPDATE statement and checks that the row was updated. If zero rows are updated (because another transaction changed the version), Hibernate throws OptimisticLockException:

@Entity
public class Order {
    @Id
    private Long id;

    @Version
    private Long version;  // managed by Hibernate — never modify this directly

    private OrderStatus status;
    private BigDecimal total;
}

The generated UPDATE:

UPDATE orders
SET status = 'PROCESSING', version = 2
WHERE id = 1 AND version = 1
-- If no rows updated (version was already > 1), Hibernate throws OptimisticLockException

The version check is atomic with the update — no separate read is required. The database's row-level locking ensures that the WHERE id = 1 AND version = 1 check and the update happen atomically.

Version column types. Long, Integer, Short are numeric version counters — Hibernate increments on every update. Instant, Timestamp use the modification timestamp — Hibernate sets to current time on update.

Numeric counters are preferred: timestamps have millisecond (or microsecond) granularity — two updates within the same millisecond on the same entity won't be detected. Numeric counters increment regardless of timing.

Never modify @Version directly. Setting order.setVersion(...) manually causes Hibernate to send an incorrect version in the UPDATE, which either incorrectly succeeds or incorrectly fails. The version field is owned by Hibernate.

Handling OptimisticLockException

OptimisticLockException (JPA) / StaleObjectStateException (Hibernate) is thrown when a version conflict is detected. At this point, the transaction has been rolled back — the entity's in-memory state is invalid.

The two responses: retry the operation or report a conflict to the caller.

When to retry. Retry is appropriate when the conflict is transient — two concurrent requests that don't logically conflict but happened to race. A payment processor that increments a counter, two requests updating different fields of the same entity:

@Retryable(
    retryFor = OptimisticLockingFailureException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100, multiplier = 2, random = true)
)
@Transactional
public Order updateOrderStatus(Long orderId, OrderStatus newStatus) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.setStatus(newStatus);
    return orderRepository.save(order);
}

Spring Retry's @Retryable retries the method on OptimisticLockingFailureException. The retry re-reads the entity with the current version and re-applies the change. Random jitter in the backoff prevents thundering herd retry storms.

Spring Boot includes Spring Retry; add the dependency and @EnableRetry on a configuration class:

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
@Configuration
@EnableRetry
public class RetryConfig {}

When not to retry. Retry is wrong when the conflict reflects a genuine business rule violation — two users attempting to claim the last available ticket, concurrent modifications to the same financial record where both changes must be visible. Retrying silently overwrites one user's intent. Surface the conflict to the user:

@Transactional
public Order claimTicket(Long eventId, Long userId) {
    try {
        Event event = eventRepository.findById(eventId).orElseThrow();
        if (event.getAvailableTickets() <= 0) {
            throw new NoTicketsAvailableException(eventId);
        }
        event.setAvailableTickets(event.getAvailableTickets() - 1);
        Ticket ticket = ticketRepository.save(new Ticket(event, userId));
        eventRepository.save(event);
        return ticket;
    } catch (OptimisticLockingFailureException e) {
        // Another request modified the event — reload and check availability
        throw new TicketClaimConflictException(
            "Concurrent modification — please try again", eventId);
    }
}

The caller (controller) translates TicketClaimConflictException to a 409 Conflict response. The user is informed and can retry at the UI level with current data.

Optimistic locking in REST APIs — the ETag pattern

HTTP ETags are the natural surface for optimistic locking in REST APIs. The ETag encodes the entity version; clients include it in If-Match on update requests:

GET /orders/42
→ 200 OK, ETag: "5", {status: "PENDING", total: 100, version: 5}

PATCH /orders/42
If-Match: "5"
{status: "PROCESSING"}

→ 200 OK if version is still 5
→ 412 Precondition Failed if version > 5 (concurrent modification)

Spring MVC handles If-Match with WebRequest.checkNotModified() or manually:

@PatchMapping("/{id}")
public ResponseEntity<OrderResponse> updateOrder(
        @PathVariable Long id,
        @RequestBody UpdateOrderRequest request,
        @RequestHeader(value = "If-Match", required = false) String ifMatch) {

    Order current = orderRepository.findById(id).orElseThrow();

    // Validate ETag
    String currentETag = "\"" + current.getVersion() + "\"";
    if (ifMatch != null && !ifMatch.equals(currentETag)) {
        return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED)
            .eTag(currentETag)
            .build();
    }

    try {
        current.setStatus(request.status());
        Order saved = orderRepository.save(current);
        return ResponseEntity.ok()
            .eTag("\"" + saved.getVersion() + "\"")
            .body(OrderResponse.from(saved));
    } catch (OptimisticLockingFailureException e) {
        return ResponseEntity.status(HttpStatus.CONFLICT).build();
    }
}

Clients that don't include If-Match proceed without version checking — a choice per endpoint depending on whether concurrent modification protection is required. The ETag value in the response tells the client the new version for subsequent updates.

Pessimistic locking — the alternative

Optimistic locking detects conflicts at commit time — the work is done before the conflict is discovered. Pessimistic locking prevents conflicts by acquiring a database lock at read time — other readers or writers wait until the lock is released.

// Pessimistic write lock — blocks all other readers and writers
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Order> findById(Long id);

// Usage
Order order = orderRepository.findById(id).orElseThrow(); // acquires FOR UPDATE lock
order.setStatus(PROCESSING);
orderRepository.save(order); // lock released on commit

PESSIMISTIC_WRITE generates SELECT ... FOR UPDATE. Other transactions attempting to lock the same row wait until the first transaction commits.

When pessimistic locking is appropriate:

  • High contention on a specific row where optimistic lock retries would dominate (ticket inventory, seat reservation)
  • Operations that must not fail due to concurrent modification (financial transfers where retry would double-charge)
  • When the cost of retry (re-executing an expensive operation) exceeds the cost of waiting

The deadlock risk. Pessimistic locks held across multiple rows can deadlock if two transactions acquire locks in different orders. Consistent lock ordering (always lock lower ID first) prevents this — the same strategy as mutex lock ordering in concurrent programming.

Timeout for pessimistic locks. Without a timeout, a transaction waiting for a pessimistic lock waits indefinitely:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
Optional<Order> findByIdForUpdate(Long id);

3000 milliseconds — the query waits up to 3 seconds for the lock. After timeout, LockTimeoutException is thrown. Handle it like OptimisticLockException — retry or surface to the caller.

Choosing between optimistic and pessimistic

Optimistic locking is the default for most entities. Contention is usually low, conflicts are rare, and the cost of an occasional retry is acceptable. The entity can be read and processed without any database overhead until commit time.

Use pessimistic locking when:

  • Conflict probability is high (multiple processes race for the same resource frequently)
  • The work done between read and write is expensive and retry would be prohibitive
  • The business semantics require exactly-once execution (financial transfers, inventory reservation)

Use neither when:

  • The entity is read-only or updated only by background jobs with no concurrent access
  • The operation is idempotent and concurrent duplication is acceptable
  • High-volume entities where version columns and conflict detection add measurable overhead

For most application entities — user profiles, orders, configurations — optimistic locking with retry on conflict is the correct default. The conflict rate in typical web applications is low enough that pessimistic locking's overhead is rarely justified.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Why Dubai Startups Lose Backend Engineers to Better Offers Every 18 Months

You relocated him from Lahore, sponsored his visa, found him an apartment in JLT. Eighteen months later he's joining a fintech in DIFC for 30% more.

Read more

Why Work From Home Shouldn’t Be Used to Lowball Developer Salaries

Working from home has opened new possibilities for developers—and companies. But it shouldn’t be an excuse to underpay talent just because they aren’t in the office.

Read more

How to Keep Clients Happy When Things Go Wrong

Even the best projects hit bumps. How you handle problems can make or break your client relationships.

Read more

Office Rules vs Contractor Rights: Where to Draw the Line

“Please follow all internal office policies as if you were an employee.” That’s where many contractor arrangements quietly start to drift off course.

Read more