AlgoInsight

System Design

Optimistic Locking Explained: Handle Concurrency Without Deadlocks

The deadlock we just watched freeze a system came from locks. So what if you used no locks at all? Optimistic locking lets threads race ahead and only checks for conflicts at the moment of writing - and it's how platforms handle millions of concurrent orders without ever deadlocking.

By Admin2026-07-1314 min readconcurrencyoptimistic-lockingpessimistic-lockingdatabaseversioning

In the last post, we watched two locks freeze an entire system: a payment thread and a cancellation thread each grabbed the order and payment records in opposite orders, formed a circular wait, and deadlocked forever. The root cause was the locks themselves. So here's a radical question: what if we used no locks at all? That's the idea behind optimistic locking - let threads race ahead without locking anything, and only check for conflicts at the moment of writing. It's a fundamentally different mindset, it makes deadlocks impossible, and it's how large platforms handle millions of concurrent orders without freezing. This post explains how it works, using the same Amazon order example - now without a single lock.

(This is the resolution of a five-part thread on concurrency, building on the deadlocks and locks posts.)

Recap: from deadlocks to a better approach

Quick recap. We learned that race conditions are prevented with pessimistic locking - locks, synchronization, and atomic operations that assume conflicts will happen and lock resources up front to prevent concurrent access. But we also saw the dark side: when threads acquire locks in inconsistent orders, they can deadlock, each waiting forever for a resource the other holds.

One way to prevent that deadlock is consistent lock ordering - making every thread acquire locks in the same order, so no circular wait can form. That works, but it stays within the pessimistic world of holding locks. Today we look at something more fundamental: an approach that avoids locks entirely, and therefore avoids deadlocks entirely, because you can't deadlock on locks you never take. That's optimistic locking.

Pessimistic vs. optimistic: the mindset shift

The whole difference is in one assumption about how often conflicts happen:

The shift is from prevention to detection. Pessimistic locking prevents conflicts by making threads wait; optimistic locking lets threads run freely and catches conflicts only if they actually occur, at write time. Since no thread ever holds a lock, no thread ever waits on another - and the whole category of deadlocks simply vanishes.

graph TD
    subgraph PESS["Pessimistic: prevent"]
    P1[Lock data up front] --> P2[Others wait] --> P3[Do work] --> P4[Release lock]
    P2 -.->|can deadlock| PX[Circular wait risk]
    end
    subgraph OPT["Optimistic: detect"]
    O1[Read data + version] --> O2[Do work, no lock] --> O3{Version still matches?}
    O3 -->|Yes| O4[Commit + increment version]
    O3 -->|No| O5[Reject → retry]
    end
This diagram contrasts the two approaches. Pessimistic locking prevents conflicts: a thread locks the data up front, others wait, it does its work, then releases the lock - but the waiting creates a risk of circular waits and deadlock. Optimistic locking detects conflicts instead: a thread reads the data along with its version, does its work without any lock, and at commit time checks whether the version still matches. If it matches, the update commits and the version increments. If it doesn't, the update is rejected and retried. No locks means no waiting and no deadlocks.

How optimistic locking works: the version column

The mechanism is elegant and simple. Each row in the database - an order, a payment record, whatever - gets a special column called version. This version is a counter that automatically increments every time the row is successfully updated.

Here's the trick. When a thread reads a row, it also reads the current version. Later, when it goes to update that row, the update only succeeds if the version in the database still matches the version the thread originally read. If it matches, the update goes through and the version increments. If it doesn't match - meaning someone else updated the row in the meantime - the update is rejected, because the thread is clearly working from stale data.

Concretely, the update carries a condition on the version:

-- Thread read the row when version was 1. Now it tries to update:
UPDATE payment
SET status = 'paid', version = 2
WHERE id = 123 AND version = 1;

-- If version is still 1 → 1 row updated → success (version becomes 2).
-- If version is already 2 (someone updated first) → 0 rows updated → CONFLICT.

If zero rows are affected, the thread knows its update lost the race - the version moved on without it - and it can retry with fresh data or handle the conflict. In frameworks like JPA/Hibernate, you don't even write this by hand: you add a @Version annotation to the entity's version field, and the framework automatically manages the version check and increment on every update. The developer just marks the field; the framework handles the concurrency control.

@Entity
public class Payment {
    @Id private Long id;
    private String status;

    @Version           // Hibernate/JPA auto-manages the version check + increment
    private int version;
}

The happy flow with optimistic locking

Let's run the Amazon order through it. The user places an order:

  1. The order is created with version 1. Payment is triggered.
  2. Thread 1 waits for the payment gateway callback (from Razorpay, Stripe, or similar). This can take a moment - the gateway is an external system.
  3. The gateway responds with a success callback.
  4. Thread 1 updates the payment record to "paid" - it reads version 1, writes with the version check, and since the version still matches, the update succeeds and the version increments from 1 to 2.

Clean and simple: because the version matched at write time, Thread 1's update committed. No lock was ever held. The thread just read, did its work (waiting on the gateway), and committed with a version check that passed.

Conflict detection: how stale updates are rejected

Now the interesting part - what happens when a second thread collides. While Thread 1 was waiting for the payment gateway, Thread 2 gets triggered: the user clicks "Cancel," or a network timeout fires. Thread 2 wants to update the same payment row - but it read the row earlier, so it's still holding version 1 in its context.

Here's the resolution. By the time Thread 2 tries to commit, Thread 1 has already updated the row and bumped the version to 2. So Thread 2's update - which says "update where version = 1" - finds no matching row (the version is now 2, not 1). The database rejects Thread 2's update as stale. Its version no longer matches; it's clearly working from out-of-date data.

sequenceDiagram
    participant T1 as Thread 1 (payment success)
    participant DB as Payment Row (version)
    participant T2 as Thread 2 (cancel / timeout)
    T1->>DB: Read row → version 1
    T2->>DB: Read row → version 1
    Note over T1,T2: Both read version 1
    T1->>DB: UPDATE ... WHERE version = 1
    DB-->>T1: Success → version now 2
    T2->>DB: UPDATE ... WHERE version = 1
    DB-->>T2: 0 rows - version is 2, not 1 → REJECTED (stale)
This sequence diagram shows optimistic conflict detection. Thread 1, handling payment success, and Thread 2, handling a cancel or timeout, both read the payment row while its version is 1. Thread 1 commits its update with a condition that the version equals 1; it succeeds, and the version becomes 2. Then Thread 2 tries to commit its update, also conditioned on version equals 1 - but the version is now 2, so no row matches and the database rejects Thread 2's update as stale. The conflict is caught cleanly at write time, with no locks and no deadlock.

And notice what didn't happen: no deadlock, no data corruption. Just a clean conflict detection. One update won, the other was cleanly rejected as stale - exactly the outcome we wanted, achieved without any thread ever waiting on a lock.

Why optimistic locking eliminates deadlocks

This is the key payoff, and it's worth stating plainly. Since no thread ever holds a lock on any resource, there is no possibility of two threads waiting on each other. No locks means no circular dependency, no system freeze, no silent blocking. Threads proceed independently, and conflicts are resolved at write time through version comparison - not through lock acquisition.

Recall the four conditions a deadlock requires (mutual exclusion, hold and wait, no preemption, circular wait). Optimistic locking demolishes the foundation: with no locks held during processing, there's no hold and wait and no circular wait - threads don't hold resources while waiting for others, because they don't hold locks at all. Remove those conditions and deadlock is structurally impossible. That's why optimistic locking isn't just a different way to handle concurrency; it's a way that makes an entire class of failures (deadlocks) unable to occur.

When to use optimistic vs. pessimistic locking

Neither approach is universally better - the right choice depends on how often conflicts actually happen.

Optimistic locking works best in high-read, low-conflict environments. Think e-commerce platforms where millions of users browse, but only a tiny fraction update the same record at the same instant. When conflicts are rare, threads almost never collide, so letting them run freely (with the occasional cheap retry when they do collide) is far more efficient than making everyone acquire locks. The cost of optimistic locking is the occasional retry, which is negligible when conflicts are rare - and you get no deadlocks and high concurrency in return.

Pessimistic locking is better for high-conflict scenarios, where collisions are frequent. Think seat booking or inventory with limited stock - where thousands of people fight over the same few rows (the last concert seat, the last unit in stock). Here, optimistic locking would cause a storm of conflicts and retries: many threads read the same version, one wins, all the rest fail and retry, and the retrying itself becomes expensive and can starve progress. When the cost of retrying failed updates exceeds the cost of holding a lock, pessimistic locking wins - you lock the contested resource so threads take clean turns instead of colliding repeatedly.

The rule of thumb: rare conflicts → optimistic (let threads run, retry the rare collision); frequent conflicts → pessimistic (lock, because retrying constantly is worse). Match the strategy to the contention.

Optimistic locking Pessimistic locking
Assumption Conflicts are rare Conflicts are likely
Mechanism No locks; version check at write Lock resource up front
On conflict Reject + retry Others wait for the lock
Deadlocks Impossible (no locks) Possible (if misused)
Best for High-read, low-conflict (browsing, e-commerce) High-conflict (seat booking, limited stock)

The open question: what if the cancellation arrives first?

Here's an edge case that reveals something deeper - and it's worth thinking hard about. In the flow above, the payment success committed first, so the cancellation was cleanly rejected as stale. But what if the order is reversed? What if the UI cancellation thread commits first - bumping the version to 2 - before the payment gateway's success callback arrives?

Now the tables turn. When the payment-success update finally arrives (still holding version 1), it becomes the stale update and gets rejected. But there's a problem: the payment actually succeeded at the gateway! The money was taken, yet your system just rejected the "paid" update and marked the order cancelled. You've got a real-world inconsistency - a charged customer with a cancelled order - that optimistic locking alone doesn't resolve. Version comparison correctly detected the conflict, but "reject the later write" is the wrong business outcome here, because that later write reflects money that genuinely changed hands.

This exposes a crucial boundary: optimistic locking handles concurrency within your database, but interactions with an external system like a payment gateway need more. The gateway is a separate source of truth, and its events (success callbacks) can arrive late, out of order, or more than once. Handling that correctly requires three additional patterns that every serious payment system uses:

The deeper lesson: concurrency control and distributed consistency are different problems. Optimistic locking (version comparison) solves the concurrency problem within your database beautifully. But once an external system with its own truth and its own timing is involved, you also need idempotency and reconciliation to handle events that arrive out of order. Recognizing where one problem ends and the other begins is exactly the kind of systems thinking that separates a working payment flow from a correct one.

Interview: Optimistic vs. pessimistic locking is a rich interview topic. The strong answer: pessimistic locks up front (assumes conflicts, prevents them, can deadlock); optimistic proceeds without locks and detects conflicts at write time via a version column (assumes conflicts are rare, no deadlocks, retries on conflict). Explain the version mechanism (update only if the version still matches, then increment) and when to use which (optimistic for low-conflict/high-read like e-commerce; pessimistic for high-conflict like seat booking). The senior-level flourish is the payment edge case: optimistic locking handles in-database concurrency, but external systems (payment gateways) need idempotency and reconciliation because their events arrive out of order. That shows you see the boundary between concurrency control and distributed consistency.

The bottom line

Optimistic locking flips the concurrency mindset from prevention to detection. Instead of locking data up front (pessimistic), it lets threads proceed without locks and validates at write time using a version column - the update succeeds only if the version still matches what the thread read, incrementing it on success and rejecting stale updates. In the Amazon example, the payment-success thread commits (version 1→2), and the concurrent cancellation thread - still holding version 1 - is cleanly rejected as stale. No locks, no deadlocks, no corruption. Because no thread ever holds a lock, the circular waits that cause deadlocks cannot form. Use optimistic locking for low-conflict, high-read workloads (e-commerce) and pessimistic for high-conflict ones (seat booking, limited stock).

And the edge case - a cancellation committing before a payment callback - reveals the boundary: optimistic locking solves in-database concurrency, but external systems like payment gateways also need idempotency, webhook retry handling, and reconciliation to stay consistent when events arrive out of order. Concurrency control and distributed consistency are related but distinct - and building correct systems means handling both. We cover concurrency control, optimistic locking, and consistency across System Design Fundamentals.


Go deeper

More articles