From Pessimistic to Optimistic Locking: A Practical Guide to Retries and Backoff
-
Jason Yang - 02 Jun, 2026
- Views —
If you’ve ever shipped a high-traffic feature — a flash sale, a coupon drop, a ticketing event — you’ve run into concurrency control sooner or later. The instinctive first move is to reach for a pessimistic lock at the database level, because correctness feels safer than throughput.
But under a real traffic spike, pessimistic locks can become the bottleneck themselves. In this post, I’ll walk through a concurrency scenario you’re likely to hit in production, and show how to replace pessimistic locking with optimistic locking plus a well-designed retry/backoff strategy.
1. The Problem: Why Pessimistic Locks Break Down
A pessimistic lock grabs a shared or exclusive lock on a database row the moment you touch it, blocking any other transaction from getting in. Consistency-wise it’s airtight — but the trade-off becomes brutal under spike traffic.
- Spike traffic kills TPS. Every request has to line up for the lock. The wait queue grows, your connection pool drains, and throughput collapses.
- The case for optimistic locking. Instead of locking the row in the database, you can manage consistency at the application layer using a version column (
@Version). Optimistic locks only check for conflicts at write time, so there’s no waiting queue — which makes them a better fit when conflicts are rare to moderate. The catch is that under extreme contention (think a true first-come-first-served event), retry costs can explode, and you’ll want a different strategy altogether — more on that later.
Pessimistic Locking with JPA
With JPA, pessimistic locking is configured at the repository query level with @Lock. JPA translates this into a SELECT ... FOR UPDATE, so the database itself holds the row.
public interface CouponRepository extends JpaRepository<Coupon, Long> {
// PESSIMISTIC_WRITE → SELECT ... FOR UPDATE (exclusive lock)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Coupon c WHERE c.id = :id")
Optional<Coupon> findByIdForUpdate(@Param("id") Long id);
}
@Service
@RequiredArgsConstructor
public class CouponService {
private final CouponRepository couponRepository;
@Transactional
public void buyCoupon(Long couponId) {
// The DB locks the row here — other transactions wait
Coupon coupon = couponRepository.findByIdForUpdate(couponId)
.orElseThrow(() -> new IllegalArgumentException("Coupon not found"));
coupon.decreaseQuantity();
// Lock is released when the transaction commits
}
}
Consistency-wise this is rock solid. But the moment traffic spikes, every request piles up behind the lock — which is exactly the connection-pool exhaustion and TPS collapse described above.
📌 How to spot pessimistic locking at a glanceThe repository has
@Lock(LockModeType.PESSIMISTIC_WRITE)or **PESSIMISTIC_READ**The generated SQL contains **FOR UPDATE**The entity itself has no annotation (locking is a query-level behavior)
Optimistic Locking with JPA
Optimistic locking flips the model: you add a @Version field to the entity, and the repository stays untouched. At write time, JPA automatically appends WHERE version = ? to the UPDATE and detects conflicts there.
@Entity
@Getter
@NoArgsConstructor
public class Coupon {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int availableQuantity;
// The version field is what makes this optimistic
@Version
private Long version;
public void decreaseQuantity() {
if (this.availableQuantity <= 0) {
// Business exception — retrying won't change the outcome, so it must NOT be retried
throw new IllegalStateException("Out of stock");
}
this.availableQuantity--;
}
}
@Service
@RequiredArgsConstructor
public class CouponService {
private final CouponRepository couponRepository;
@Transactional
public void buyCoupon(Long couponId) {
// No lock — other transactions can enter at the same time
Coupon coupon = couponRepository.findById(couponId)
.orElseThrow(() -> new IllegalArgumentException("Coupon not found"));
coupon.decreaseQuantity();
// On commit, JPA compares the version. Mismatch → exception
}
}
Side-by-Side Comparison
| Aspect | Pessimistic Lock | Optimistic Lock |
|---|---|---|
| Configured at | Repository (@Lock) | Entity (@Version) |
| Generated SQL | SELECT ... FOR UPDATE | UPDATE ... WHERE version = ? |
| Conflict detected | Read time (others wait) | Write time (exception thrown) |
| Conflict handled by | DB queues them up (automatic) | App has to retry (manual) |
| Sweet spot | Very high contention | Low to moderate contention |
| Main risk | Connection-pool exhaustion, deadlocks | Retry storms under heavy contention |
📌 What
@Versiondoes NOT protect you fromOptimistic locking only verifies whether someone else modified the row between your read and your write. It says nothing about whether the operation makes business sense — for example, decrementing stock that’s already zero. You still need explicit checks for that in your domain logic, as shown above. And the
IllegalStateExceptionfrom that check is not retryable — retrying won’t magically refill the stock. We’ll handle this distinction explicitly via theisRetryablecheck in Section 3.
2. Four Things You Need to Get Right
Optimistic locking isn’t just about adding @Version. The hard part is the surrounding design — making sure conflicts don’t surface to the user as errors, and that the system holds up under contention. Here are the four pieces you can’t skip.
① Catch the Right Exceptions, Cap the Retries
You need to catch the specific exceptions thrown on conflict (for example, ObjectOptimisticLockingFailureException in Spring Data), and you need a hard ceiling on retries. Unbounded retries will take your service down faster than the original conflict ever would have.
② Spread the Load with Backoff
Retrying immediately after a conflict almost guarantees another conflict — and floods the database with wasted queries. Use exponential backoff combined with jitter so retries spread out over time instead of dogpiling.
③ Plan for the Failure Case (Fallback)
Sooner or later, even with backoff and retries, some requests will exhaust the limit and fail. A technical failure shouldn’t become a broken user experience. Design the fallback as part of the feature, not as an afterthought.
④ Make It Observable
If you can’t see your retry rate and exhaustion rate, you’re picking thresholds by gut feel. Expose retry.attempts and retry.exhausted counters via Micrometer or similar. A jump from a 1% retry rate to 30% is the kind of signal that catches a hot key before it takes down the service. In production, metrics matter more than code.
3. Implementation: A Custom AOP for Retries with Exponential Backoff
⚠️ The trap that bites everyone first: transaction boundaries
This is the #1 reason hand-rolled retry logic fails to work as expected.
If you slap
@Transactionaland@OptimisticRetryon the same method, the transaction is already marked rollback-only by the time the conflict exception fires. Retrying inside the same transaction will fail every single time. The retry has to live outside the transaction boundary.[Wrong] Retry inside @Transactional → same transaction → always fails
[Right] @OptimisticRetry → @Transactional → fresh transaction on each attemptIn practice, declare
@Orderon the retry aspect, or — better — split the retry-owning Facade/Service from the transactional Service entirely.There’s one more gotcha: self-invocation. If you call a
@Transactionalmethod viathis.foo()from within the same class, Spring’s proxy never gets a chance to intercept — meaning neither@Transactionalnor@OptimisticRetrywill apply.@Orderalone won’t save you here. Splitting concerns across separate beans is the most reliable fix.
Spring Retry already gives you @Retryable(retryFor=..., backoff=@Backoff(delay=50, multiplier=2, random=true)) as a one-liner. So when does it make sense to roll your own AOP?
- You need different backoff policies for different exception types (optimistic-lock conflicts vs. DB deadlocks vs. external API failures)
- You want retry metrics tagged with business context (coupon ID, user ID)
- You want to wire up the fallback queue in the same place
For straightforward retries, Spring Retry is plenty. The example below is intentionally simplified for clarity.
① The Custom Annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OptimisticRetry {
int maxAttempts() default 3; // Total attempts including the first (3 = 1 initial + 2 retries)
long backoff() default 50; // Base backoff in milliseconds
}
② The Aspect (Exponential Backoff with Jitter)
@Aspect
@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE) // Must wrap @Transactional, not the other way around
public class OptimisticRetryAspect {
// 1) Pure optimistic-lock conflicts (version mismatch)
private static final Set<Class<? extends Throwable>> OPTIMISTIC_LOCK_EXCEPTIONS = Set.of(
ObjectOptimisticLockingFailureException.class, // Spring Data
OptimisticLockException.class, // JPA
StaleObjectStateException.class // Hibernate
);
// 2) Transient DB-level conflicts (deadlock, lock timeout) — retryable, but a different beast
private static final Set<Class<? extends Throwable>> TRANSIENT_DB_EXCEPTIONS = Set.of(
CannotAcquireLockException.class
);
// Hard upper bound on backoff — guards against absurdly long sleeps and overflow
private static final long MAX_DELAY_MS = 1_000L;
@Around("@annotation(optimisticRetry)")
public Object doRetry(ProceedingJoinPoint joinPoint, OptimisticRetry optimisticRetry) throws Throwable {
int maxAttempts = optimisticRetry.maxAttempts(); // total attempts, first included
long baseDelay = optimisticRetry.backoff();
Throwable last = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// Invoke the wrapped logic — typically a separate @Transactional bean,
// so each attempt gets a fresh transaction
return joinPoint.proceed();
} catch (Throwable e) {
if (!isRetryable(e)) {
throw e; // Business exceptions (e.g. out of stock) propagate immediately
}
log.warn("[Conflict] attempt {}/{} failed, retrying. cause={}",
attempt, maxAttempts, e.getClass().getSimpleName());
last = e;
if (attempt == maxAttempts) {
break;
}
// Full Jitter (per AWS Architecture Blog)
// sleep ∈ [0, min(baseDelay * 2^(attempt-1), MAX_DELAY_MS)], uniform
// The cap keeps things sane even when attempt counts grow large
long exponential = baseDelay * (1L << (attempt - 1));
long cap = Math.min(exponential, MAX_DELAY_MS);
long sleepTime = ThreadLocalRandom.current().nextLong(0, cap + 1);
try {
Thread.sleep(sleepTime);
} catch (InterruptedException ie) {
// Restore the interrupt flag so the thread pool / container can shut down cleanly
Thread.currentThread().interrupt();
throw ie;
}
}
}
log.error("[Retries exhausted] giving up after {} attempts", maxAttempts);
throw last;
}
private boolean isRetryable(Throwable e) {
return OPTIMISTIC_LOCK_EXCEPTIONS.stream().anyMatch(c -> c.isInstance(e))
|| TRANSIENT_DB_EXCEPTIONS.stream().anyMatch(c -> c.isInstance(e));
}
}
💡 The hidden cost of
Thread.sleepSleeping holds the Tomcat worker thread for the entire duration. Trading “connection-pool exhaustion” (the pessimistic-lock problem) for “thread-pool exhaustion” isn’t a win. If your traffic is genuinely extreme, look at non-blocking retries (WebFlux, coroutines) or push the work into a queue instead.
When
InterruptedExceptiondoes fire, always callThread.currentThread().interrupt()to restore the interrupt flag. Otherwise your thread pool and container can miss the shutdown signal, and graceful shutdown breaks.And keep retryable exceptions (
isRetryable) strictly separated from business exceptions likeIllegalStateException. Retrying a non-retryable exception just shows the user the same error N times.
📚 Why split the exception set into two groups
OPTIMISTIC_LOCK_EXCEPTIONSandTRANSIENT_DB_EXCEPTIONSare separate sets because they aren’t really the same problem.ObjectOptimisticLockingFailureExceptionand friends mean a version mismatch — “someone else committed first”. A short backoff usually clears it up.CannotAcquireLockExceptionmeans a DB-level lock acquisition failure (deadlock, lock timeout). That’s not an optimistic-lock conflict at all. You can still retry it, but in production you’ll often want a different backoff policy (longer waits, fewer attempts) tailored to the failure mode.In larger systems, these often live in entirely separate aspects rather than a single shared one.
4. When the Retries Run Out
No matter how clean your backoff and jitter are, some requests will exhaust the retry budget. If your only fallback is an error page, a technical conflict becomes a broken user experience. Senior-level design treats the fallback as a product decision, not just a try/catch.
💡 Fallback patterns worth stealing****Drop into an async queue, notify when ready. Show the user something like “We’re processing a high volume of orders right now — we’ll text you when yours is ready,” and push the request into a message queue for asynchronous handling.Soft-fail UI. Instead of an error dialog, show a progress bar or a refresh prompt. Buy yourself time without burning the UX.
Going Further: When Optimistic Locking Itself Isn’t Enough
For a real flash sale — 1,000 units, 100,000 concurrent buyers — optimistic locking will conflict 99%+ of the time and effectively stops working. At that point you need a different model entirely.
- Redis as a gate. Use
DECRor a Lua script to decrement stock atomically. Only callers that pass the gate hit the database; the DB becomes an eventually-consistent record of truth. - A first-come-first-served queue (Kafka/SQS). Drop incoming requests into a queue immediately, then drain at a controlled rate. Database load flattens out.
⚠️ A Redis gate is only half the design — the other half is reconciliation
Don’t stop at “Redis decremented, we’re good.” You also need to handle Redis success but DB write failure, queue redelivery causing duplicate processing, and dropouts from network partitions. In practice this means idempotency keys (so duplicates become no-ops), the outbox pattern (atomically tying DB writes to message publication), and a periodic reconciliation job that verifies Redis and the database still agree.
The bigger point: instead of treating “pessimistic → optimistic” as a one-way upgrade, let the traffic shape and conflict rate dictate the locking model itself.
Wrapping Up
Concurrency control isn’t really a question of which annotation to use. It’s a trade-off between keeping infrastructure load under control (think connection-pool exhaustion) and maintaining business continuity (think user-facing UX).
Whether you’re refactoring an existing system or designing a new one, get into the habit of writing down the backoff policy and the fallback path alongside the lock choice. The architecture you end up with will be a lot more resilient for it.