Optimistic vs. Pessimistic Locking: Lessons in Concurrency Control from a Real-World Incident

Optimistic vs. Pessimistic Locking: Lessons in Concurrency Control from a Real-World Incident

Hello!

This is the Qualiteg product development team!

"We implemented optimistic locking, but we're still getting conflict errors..."

This actually happened to us.

In this article, we explain the difference between optimistic and pessimistic locking through a real incident we ran into.

Rather than abstract explanations, we aim for content that lets you truly feel
"why it is needed" and "what problems it can solve"
.


Table of Contents

  1. Background: Mysterious Errors Under Parallel Load
  2. A World Without Locks: Why Conflicts Happen
  3. Introducing Optimistic Locking: Expectations vs. Reality
  4. The Limits of Optimistic Locking: The Problem It Couldn't Solve
  5. Solving It with Pessimistic Locking
  6. Implementation Pitfalls
  7. Which Should You Choose: Decision Criteria
  8. Wrap-Up

1. Background: Mysterious Errors Under Parallel Load

1.1 System Overview

What we were building was
an API server that switches between multiple workspaces
.

It was part of one of our AI-related products, and when we ran combined integration and load tests, the error would occur on rare occasions.

Users can create multiple working sets (configuration sets) and select one of them as "active."

1.2 The Error That Occurred

When requests were processed in parallel, the following error occurred at random.

Error: HTTP Error 500: Cannot modify protected workspace 'default'

Run alone, the request succeeds; with many clients hitting the server at once, it fails.

What's more, a different request would fail each time.

These are the classic symptoms of a race condition. The most unpleasant kind of bug there is.


2. A World Without Locks: Why Conflicts Happen

2.1 Definitions

Before getting into the main topic, let's define the terms used in this article.

Global state is state that is shared by multiple clients and referenced implicitly. In this article's example, the information "which workspace is currently active" is exactly that.

2.2 The Cause

Our investigation showed the problem occurred in the following scenario.

From client A's point of view, it looks like this:

  1. Activated workspace_A, their own workspace
  2. Tried to add settings to that workspace
  3. For some reason, got back an error about a change to default instead

What happened was this: in the window between client A sending its request and the request reaching the server, client B had switched the active workspace (the global state).

2.3 The Essence of the Problem

So what was the essence of the problem? Right—the essence lies in
operations that span multiple API calls
.

await client.switch("workspace_A");   // Step 1: switch
await client.addSettings(data);       // Step 2: add settings ← problem here
await client.process(input);          // Step 3: run processing

There is a time gap between step 1 and step 2, during which other clients can change the global state.

This is a classic race condition known as the

TOCTOU (Time Of Check To Time Of Use) problem

.

In database terms, it is close to a "transaction" concern.


3. Introducing Optimistic Locking: Expectations vs. Reality

3.1 What Is Optimistic Locking?

The first thing that comes to mind here is optimistic locking.

"Optimistic locking" is a scheme based on the assumption that conflicts rarely happen.

The basic mechanism works like this.

  1. When fetching data, receive version information (such as an ETag)
  2. Include the version information in update requests
  3. The server permits the update only when the versions match
  4. On mismatch, it returns "409 Conflict"

And when a conflict does occur, this is what happens.

3.2 What Optimistic Locking Can Solve

So what can optimistic locking solve?

Yes—optimistic locking can solve the Lost Update problem.


4. The Limits of Optimistic Locking: The Problem It Couldn't Solve

4.1 What We Expected

Jumping straight from "mutual exclusion" to "optimistic locking," we went ahead and implemented it.

Believing it would solve our conflict problem.

4.2 Reality

And yet...the same errors kept occurring.

4.3 Why It Didn't Work

As we analyzed and debugged why it failed, we noticed (and learned) three reasons.

1. What is conflicting is not the workspace's "content"

What is conflicting is the global state: "which workspace is active."

2. Adding an ETag accomplishes nothing

An ETag checks "has the data's content changed?" It cannot check "has the active workspace changed?"

3. The resources are different to begin with

Client A wants to operate on workspace_A, but the server is about to operate on default. This is a conflict over which resource gets operated on.

4.4 The Fundamental Limits of Optimistic Locking

✅ Optimistic locking can handle:
   - Concurrent writes to a single resource
   - Two people editing the same file at once

❌ Optimistic locking cannot handle:
   - Protecting operations that span multiple API calls
   - Protecting global state

Optimistic locking can do "change detection"; what it cannot do is "operation-level mutual exclusion".

More precisely, optimistic locking provides exclusivity only for "state transitions of a single resource" and does not guarantee "consistency across multiple resources or multiple operations".

This is essentially the same problem as the database transaction-boundary discussion we touched on earlier.

Having forgotten that essence, we failed spectacularly.

4.5 Aside: Could a Different API Design Have Solved It?

Now, battle-hardened engineers may have read this far and thought,
"couldn't optimistic locking solve this too, if the API design were changed?"
— and you would not be alone.

You are exactly right!

Had the API design followed REST principles, we would have noticed a much cleaner optimistic-locking solution

With the improved design, the request itself carries the target of the operation, so it does not depend on a global "active state." Applying optimistic locking (ETags) per workspace would detect conflicts.

So why did we choose pessimistic locking?

In short, my own judgment fell short... but to offer a little more of an excuse:

1. Backward compatibility of the existing API

Many clients were already using the current API, and we sensed an API change would require modifying every one of them

2. The "active state" pattern is not unusual

In fact, this pattern is used by all sorts of tools, and the current design followed it

  • Git: git commit operates on the "current branch"
  • Shell: commands run in the "current directory"
  • Editor: saves to the "current file"

3. The cost and time of a design change

The problem was happening "now," and an API redesign looked like it would take time

The lessons, summarized, are as follows.

  • It is less a limitation of optimistic locking than that
    designs that depend on global state pair poorly with optimistic locking
  • For new designs, you should consider a RESTful API that makes the resource explicit.
  • In existing systems, pessimistic locking is often the realistic way out

5. Solving It with Pessimistic Locking

5.1 What Is Pessimistic Locking?

So, to solve the race condition without breaking the existing API design, we decided to introduce pessimistic locking.

"Pessimistic locking" is a scheme based on the assumption that conflicts happen frequently.

The basic mechanism works like this.

  1. Before starting an operation, explicitly acquire a lock
  2. While the lock is held, other clients are blocked (or rejected)
  3. After the operation completes, release the lock

When another client accesses the resource while it is locked, this is what happens.

Note: 423 Locked is a status code defined by the WebDAV extension (RFC 4918). Typical REST APIs sometimes use 409 Conflict or 429 Too Many Requests instead. Which to use is a matter of API design policy, but when you want to communicate "this is locked" unambiguously, 423 is the appropriate choice.

5.2 Applying It to Our Problem

With pessimistic locking, the conflict is resolved as follows.

5.3 Implementation Notes

Here are three important points when implementing pessimistic locking.

1. Deadlock prevention

2. Preventing forgotten lock releases

// Guarantee release with try-finally
const lock = await acquireLock(resource);
try {
    await doSomething();
} finally {
    await releaseLock(lock);
}

3. Setting timeouts

Give locks an expiration so they are released automatically even if a client terminates abnormally.


6. Implementation Pitfalls

How has it been so far?

Truth be told, we stumbled over plenty of things even before the locking itself, so for posterity we want to leave some of it here.

These are problems we actually hit while implementing and operating pessimistic locking, along with how we dealt with them.

6.1 A Problem Before Locking: A Bug in the Underlying Logic

Problem: lock validation succeeds, yet the operation fails

Client: LOCK("workspace_A") → success
Client: switch("workspace_A") → success
Client: addSettings(data) → 423 Locked ??

Why? We should be holding the lock...

Cause: there was a bug in the server-side logic that determines the active workspace.

Value returned by the server's getActiveWorkspaceName():
  Expected: "workspace_A" (the one the client activated)
  Actual:   "default" (a bug made it always return default)

→ The server checks the lock on "default"
→ The client only holds the lock on "workspace_A"
→ 423 Locked

Lesson: even if the locking mechanism itself is correct, a bug in the logic that identifies what to lock brings everything down. This one nearly made us cry.

When debugging lock issues, always log "which lock is being checked."

6.2 Proxy/Gateway Transparency Problems

Problem: the DELETE request that releases the lock times out

Client: DELETE /lock/workspace_A
             X-Lock-Id: abc123
             Body: { "resource": "workspace_A" }

→ The request times out after 120 seconds

Cause: the nginx reverse proxy configuration was (under certain conditions) not forwarding the body of DELETE requests.

# nginx.conf (the problematic configuration)
location /api/ {
    proxy_pass http://backend;
    
    # Configured to forward bodies only for POST/PUT/PATCH
    # Never anticipated a DELETE with a body
}

The HTTP spec does permit a body on a DELETE request. However,
many proxies and frameworks do not expect a DELETE to carry a body
at all
. The nginx defaults are fine, but if you have custom configuration in place, watch out.

Lessons:

  • Lock operations may need a request body
  • Verify the traffic at the proxy layer (full request/response logs)
  • If possible, use query parameters or headers instead of a DELETE body

6.3 Getting the Lock Design Wrong

Problem: parallel tests would not run, so the system got "extended" to accept multiple lock IDs at once

// The wrong fix
Client: X-Lock-Id: lock_A, lock_B, lock_C  // send multiple locks
Server: allow if any one lock is valid  // the lock loses all meaning!

Result: parallel execution worked, but the lock lost its meaning entirely. We ended up reverting it later. This was a case of AI coding making a mistake: in trying to solve the immediate problem, it forced in this preposterous implementation.

The correct fix: we gave up on parallel tests and changed them to run serially.

# Change how the tests are run
node --test --test-concurrency=1 tests/*.test.js

Lessons:

  • AI coding tends to make exactly this kind of "quick fix that destroys the essence." Never trust the AI completely—without review by someone who understands the architecture, things can go very wrong.
  • A change that "loosens" a locking mechanism is almost always a mistake
  • "It has a lock but doesn't work" means the usage is wrong, not the lock
  • Never break production code for the sake of tests

6.4 Friction with Test Design

Problem: running tests in parallel produced conflict errors

Test A: LOCK("default") → success
Test B: LOCK("default") → waiting...
Test C: LOCK("default") → waiting...
Test D: LOCK("default") → timeout!

Cause: every test was trying to take the lock on the same resource (default). Naturally, this kind of parallel operation gets made to wait (waiting is precisely how breakage is avoided), so the tests timed out.

Solution: we split the tests into two groups.

tests_for_functions/    ← no locks needed, parallel OK
  - read-only functional tests
  - tests that do not modify state

tests_for_policy_edit/  ← locks required, must run serially
  - tests that create or modify workspaces
  - tests that modify global state

Lessons:

  • When you introduce pessimistic locking, revisit your test strategy too
  • Tests that "cannot run in parallel" are not lower quality
  • Tests that modify global state should not run in parallel in the first place

6.5 Why Lock Leaks Are Hard to Detect

Problem: occasional 423 errors with no reproducible pattern

// Problematic code
await acquireLock("resource");
await doSomething();  // if an exception occurs here...
await releaseLock("resource");  // this never runs!

What makes it hard:

  • The problem does not surface unless an exception occurs
  • It only happens in production (tests pass)
  • The logs make it hard to pinpoint the cause

Countermeasures:

// 1. Enforce a withLock pattern
async function withLock(resource, operation) {
    const lock = await acquireLock(resource);
    try {
        return await operation();
    } finally {
        await releaseLock(resource, lock);
    }
}

// 2. Ban direct acquire/release (catch in code review)
// 3. Set appropriate lock timeouts (auto-release on leak)
// 4. Monitor lock hold times (alert on abnormally long locks)

Lesson: design on the assumption that lock leaks will happen. Automatic release via timeouts is mandatory, so you never face the desperate scenario of the whole system grinding to a halt. No system is perfect.


7. Which Should You Choose: Decision Criteria

7.1 When Optimistic Locking Fits

Condition Reason
Conflicts are rare The cost of retrying is low
Access targets a single resource ETag-based detection works well
Read-heavy, write-light Avoids locking overhead

Examples: editing user profiles, updating articles, changing configuration files

7.2 When Pessimistic Locking Fits

Condition Reason
Conflicts are frequent The cost of retrying is high
Atomicity across multiple operations is required Protects an entire sequence of operations
Global state needs protecting Protects the resource selection itself

Examples: inventory management, bank account balance updates, multi-step business flows

7.3 Comparison Table

Aspect Optimistic locking Pessimistic locking
When conflicts are detected At write time At lock acquisition
Behavior on conflict Retry Wait / reject
Implementation complexity Low to medium Medium to high
Scalability High Medium
Deadlocks None Possible
Scope of protection Single resource Multiple resources and operations

7.4 Using Both Together

In real systems, the two are often used side by side.

[Optimistic locking]              [Pessimistic locking]
- Updates to individual resources - Operations spanning multiple resources
- Low-contention operations       - High-contention operations
- Read-centric processing         - Multi-step transactions

8. Wrap-Up

8.1 What We Learned

1. Optimistic locking is not a cure-all

It can do "change detection" but not "operation-level exclusion". Operations spanning multiple API calls fundamentally cannot be protected by it.

2. Understanding the essence of the problem matters

Being clear about "what is conflicting with what" is essential. Distinguish between a conflict on a single resource and a conflict on global state.

3. Choose the right scheme

Consider conflict frequency, the nature of the operations, and system requirements—and combine both approaches when needed.

4. Pessimistic locking is not "old-fashioned design"

When contention is inherent to the design, pessimistic locking is the most honest solution. The equation "optimistic = modern, pessimistic = legacy" is simply wrong.

8.2 A Caution When Using AI Coding Agents

These days, AI coding agents like Claude Code are used more and more, but concurrency and locking implementations call for extra care.

Tendencies of AI agents

In our experience, AI coding agents show the following tendencies.

  • Unfamiliar with concurrent programming: considerations for non-deterministic problems—race conditions, deadlocks—tend to get dropped
  • Tries to solve the immediate problem head-on: asked "how do I fix this error?", it tends to propose local patches
  • Slow to notice design-level perspectives: even when a more fundamental solution exists, it tries to solve things within the code it was shown

In this article's case

Our problem, too, had another solution one level of perspective up.

The immediate problem:
  "How do we prevent conflicts on global state?"
  → mutual exclusion via pessimistic locking

One level up:
  "Could the design avoid depending on global state in the first place?"
  → a RESTful API design that makes resources explicit
  → per-resource optimistic locking would suffice

Ask an AI agent to "fix this error" and it may well propose implementing pessimistic locking. That is not wrong in itself, but it is unlikely to notice the alternative—that revisiting the API design would have allowed a simpler solution with optimistic locking.

Keeping the AI Under Control

To use AI coding agents effectively, the human side must hold the architecture-level perspective.

  • Instead of "solve this problem," ask "what is the cause of this problem? Can it be solved at the design level?"
  • Do not accept the AI's proposal as-is; dig deeper with "is there a simpler way?"
  • Review anything touching concurrency with particular care

AI is a powerful tool, but design decisions must be led by humans. Especially in complex areas like concurrency, do not swallow the AI's proposals whole—make a point of steering from one level of perspective above.

In Closing

Concurrency problems have the nasty property that working once is no guarantee of safety. Code that runs fine in development can surface problems under production load—it happens all the time.

Do not assume "we added optimistic locking, so we're fine." What matters is coolly analyzing whether optimistic locking can actually solve the problem you are trying to solve.

And the greatest danger in concurrent design may well be "the designer themselves letting it stay vague where the state actually lives".

In this article's example too, had we clearly recognized the existence of the global state called "the active workspace," we might have chosen the right countermeasure from the start.

Where does the state live, who can change it, and how is it protected—being able to answer these questions clearly is the first step of concurrent design.

We hope this helps anyone wrestling with similar problems!

See you next time!


References

Read more