DEV Community

Cover image for Building an API on Top of MVCC
Derek Mwale
Derek Mwale

Posted on

Building an API on Top of MVCC

Most developers encounter concurrency as a problem they need to fix.

Two users edit the same record.

Two requests arrive at the same time.

One update overwrites another.

A transaction reads data that changes before it finishes.

Then someone says:

"We need locking."

And suddenly the code becomes full of mutexes, locks, retries, and complicated transaction logic.

But there is another way to think about concurrency.

Instead of asking:

"How do I stop everyone from touching the same data?"

we can ask:

"What if everyone gets a consistent view of the data while the database manages the conflicts?"

That is where MVCC — Multi-Version Concurrency Control — becomes interesting.

MVCC is one of those database concepts that looks like an implementation detail until you start building APIs around it.

Then you realize something strange:

MVCC can become part of the API's concurrency model.

Instead of exposing only:

GET /users/42
PATCH /users/42
Enter fullscreen mode Exit fullscreen mode

we can build an API that understands:

versions
snapshots
concurrent writes
conflicts
optimistic concurrency
transactions
read consistency
Enter fullscreen mode Exit fullscreen mode

The API stops pretending that data is static.

It starts acknowledging that multiple clients are changing reality simultaneously.

And that's where things get interesting.


1. The Problem MVCC Solves

Imagine two clients editing the same document.

Client A
   |
   | GET document
   v
Version 10

Client B
   |
   | GET document
   v
Version 10
Enter fullscreen mode Exit fullscreen mode

Both clients see version 10.

Client A changes the title: ""

Title = "Distributed Systems"
Enter fullscreen mode Exit fullscreen mode

Client B changes the author:

Author = "Derek"
Enter fullscreen mode Exit fullscreen mode

Now both submit updates.

Without concurrency control:

Version 10
    |
    +---- Client A
    |
    +---- Client B
Enter fullscreen mode Exit fullscreen mode

One update might overwrite the other.

This is called a lost update.

The database needs some way to reason about concurrent versions.

That's where MVCC enters.


2. What Is MVCC?

Multi-Version Concurrency Control means that the database can maintain multiple versions of data rather than treating every row as one mutable object.

Conceptually:

users.id = 42

Version 1
name = Derek
version = 1

Version 2
name = Derek Mwale
version = 2

Version 3
name = Derek Mwale
email = derek@example.com
version = 3
Enter fullscreen mode Exit fullscreen mode

Different transactions can observe different versions depending on when they started and what isolation level they're using.

Instead of:

One row
   |
   v
Overwrite
Enter fullscreen mode Exit fullscreen mode

we can think:

             +--> Version 1
             |
Record ------+--> Version 2
             |
             +--> Version 3
Enter fullscreen mode Exit fullscreen mode

The database determines which version is visible to a transaction.

This is powerful because readers don't necessarily have to block writers.

And writers don't necessarily have to block readers.

That's one of the central ideas behind MVCC.


3. Why This Matters for APIs

An API is a concurrency boundary.

Imagine thousands of clients interacting with:

GET /products/42
PATCH /products/42
Enter fullscreen mode Exit fullscreen mode

The API doesn't control when clients read the resource.

Client A could read at:

10:00:01
Enter fullscreen mode Exit fullscreen mode

Client B could read at:

10:00:02
Enter fullscreen mode Exit fullscreen mode

Then both could write at:

10:00:05
Enter fullscreen mode Exit fullscreen mode

The API therefore needs a way to determine:

Is this client updating the version it actually read?

This is where MVCC and API design meet.

We can expose the database's notion of version to clients.

For example:

{
  "id": 42,
  "name": "Derek",
  "version": 7
}
Enter fullscreen mode Exit fullscreen mode

Now version becomes part of the API contract.


4. Optimistic Concurrency Control

Suppose the client retrieves:

{
  "id": 42,
  "name": "Derek",
  "version": 7
}
Enter fullscreen mode Exit fullscreen mode

The client edits the record.

It sends:

PATCH /users/42
If-Match: 7
Enter fullscreen mode Exit fullscreen mode

with:

{
  "name": "Derek Mwale"
}
Enter fullscreen mode Exit fullscreen mode

The server effectively says to the database:

UPDATE users
SET name = 'Derek Mwale',
    version = version + 1
WHERE id = 42
  AND version = 7;
Enter fullscreen mode Exit fullscreen mode

If one row is updated:

Success
Enter fullscreen mode Exit fullscreen mode

If zero rows are updated:

Conflict
Enter fullscreen mode Exit fullscreen mode

Why?

Because somebody changed the record first.

The API can return:

409 Conflict
Enter fullscreen mode Exit fullscreen mode

This is optimistic concurrency control.

The API doesn't lock the record while the user is editing it.

It simply verifies that the version hasn't changed before accepting the update.


5. The API Becomes Version-Aware

A traditional API might return:

{
  "id": 42,
  "name": "Derek"
}
Enter fullscreen mode Exit fullscreen mode

An MVCC-aware API might return:

{
  "id": 42,
  "name": "Derek",
  "version": 17
}
Enter fullscreen mode Exit fullscreen mode

That tiny field changes the semantics of the API.

Now the client has a snapshot identity.

The client isn't merely holding:

User 42
Enter fullscreen mode Exit fullscreen mode

It is holding:

User 42 @ Version 17
Enter fullscreen mode Exit fullscreen mode

That is a much more precise statement.


6. ETags Are Basically Version Tokens

HTTP already gives us an elegant mechanism for this.

The server could return:

ETag: "user-42-v17"
Enter fullscreen mode Exit fullscreen mode

The client later sends:

If-Match: "user-42-v17"
Enter fullscreen mode Exit fullscreen mode

The server checks whether the resource is still version 17.

If yes:

update
Enter fullscreen mode Exit fullscreen mode

If not:

412 Precondition Failed
Enter fullscreen mode Exit fullscreen mode

Conceptually:

Client
   |
   | GET
   v
Resource v17
   |
   | edit
   v
If-Match: v17
   |
   v
Server
   |
   +--> still v17 -> update
   |
   +--> now v18   -> reject
Enter fullscreen mode Exit fullscreen mode

This is an incredibly clean bridge between HTTP semantics and database concurrency control.


7. MVCC Gives Us More Than Versions

The real power of MVCC isn't just:

version = 17
Enter fullscreen mode Exit fullscreen mode

It is the idea of snapshots.

Imagine a transaction starts at time T1.

T1
 |
 v
Snapshot S1
Enter fullscreen mode Exit fullscreen mode

While the transaction is running, another transaction modifies the database.

S1
 |
 +---- Transaction A
 |
 +---- Transaction B modifies data
Enter fullscreen mode Exit fullscreen mode

Transaction A can continue seeing a consistent snapshot depending on the database's isolation semantics.

This is incredibly useful for APIs that perform complex reads.

Imagine:

GET /analytics/dashboard
Enter fullscreen mode Exit fullscreen mode

The endpoint might query:

orders
payments
customers
inventory
Enter fullscreen mode Exit fullscreen mode

If every query observes a different state, the dashboard could become internally inconsistent.

A consistent transaction snapshot can help.


8. Building a Snapshot API

Imagine exposing:

POST /snapshots
Enter fullscreen mode Exit fullscreen mode

The server returns:

{
  "snapshot_id": "snap-93821",
  "created_at": "2026-08-21T08:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

Then:

GET /users/42?snapshot=snap-93821
Enter fullscreen mode Exit fullscreen mode

and:

GET /orders?snapshot=snap-93821
Enter fullscreen mode Exit fullscreen mode

Both reads could conceptually refer to the same logical point in time.

Now the API supports:

"Show me the world as it looked at this snapshot."

This can be extremely powerful for reporting systems, debugging, auditing, and long-running reads.


9. But Don't Expose Database Internals Blindly

There is an important warning here.

MVCC implementation details differ between databases.

PostgreSQL, MySQL/InnoDB, SQL Server, and other systems don't implement MVCC identically.

Therefore an API shouldn't necessarily expose:

PostgreSQL transaction ID
Enter fullscreen mode Exit fullscreen mode

or:

internal tuple version
Enter fullscreen mode Exit fullscreen mode

Instead, create an application-level concurrency token.

For example:

{
  "id": 42,
  "version": 18
}
Enter fullscreen mode Exit fullscreen mode

or:

ETag: "8f3a91..."
Enter fullscreen mode Exit fullscreen mode

The database can internally use MVCC.

The API exposes a stable abstraction.

That's an important architectural boundary.


10. A Simple Architecture

We can build the system like this:

                  Client
                    |
                    v
              +-----------+
              | API Layer |
              +-----------+
                    |
                    v
             +-------------+
             | Concurrency |
             | Controller  |
             +-------------+
                    |
                    v
             +-------------+
             | Transaction |
             +-------------+
                    |
                    v
             +-------------+
             | MVCC DB     |
             +-------------+
Enter fullscreen mode Exit fullscreen mode

The concurrency controller translates API-level concepts into database-level operations.

For example:

If-Match: version-17
        |
        v
WHERE version = 17
        |
        v
UPDATE
Enter fullscreen mode Exit fullscreen mode

This is where the API becomes interesting.

It isn't simply forwarding SQL.

It is translating distributed client behavior into transactional database semantics.


11. Implementing the Update

Suppose our table looks like:

CREATE TABLE documents (
    id BIGSERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT NOT NULL,
    version BIGINT NOT NULL DEFAULT 1,
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
Enter fullscreen mode Exit fullscreen mode

The client reads:

{
  "id": 10,
  "title": "Distributed Systems",
  "content": "...",
  "version": 4
}
Enter fullscreen mode Exit fullscreen mode

The client updates:

PATCH /documents/10
If-Match: "4"
Enter fullscreen mode Exit fullscreen mode

The server executes:

UPDATE documents
SET
    title = $1,
    content = $2,
    version = version + 1,
    updated_at = NOW()
WHERE
    id = $3
    AND version = $4;
Enter fullscreen mode Exit fullscreen mode

Then inspect the affected rows.

1 row -> success
0 rows -> conflict
Enter fullscreen mode Exit fullscreen mode

That is a very small amount of code.

But conceptually, we just created optimistic concurrency at the API boundary.


12. What Happens During a Conflict?

Suppose both clients read:

version = 4
Enter fullscreen mode Exit fullscreen mode

Client A updates first.

4 -> 5
Enter fullscreen mode Exit fullscreen mode

Client B then tries:

WHERE version = 4
Enter fullscreen mode Exit fullscreen mode

No row matches.

The API returns:

409 Conflict
Enter fullscreen mode Exit fullscreen mode

with something like:

{
  "error": "VERSION_CONFLICT",
  "message": "The resource has changed since you last read it.",
  "current_version": 5
}
Enter fullscreen mode Exit fullscreen mode

Now the client can react.

Maybe it fetches the latest version.

Maybe it merges changes.

Maybe it asks the user.

Maybe the agent automatically retries.

The API doesn't silently destroy data.


13. Automatic Merge

Here's where it gets more interesting.

Suppose the original document is:

{
  "title": "Distributed Systems",
  "author": "Derek"
}
Enter fullscreen mode Exit fullscreen mode

Client A changes:

title
Enter fullscreen mode Exit fullscreen mode

Client B changes:

author
Enter fullscreen mode Exit fullscreen mode

A simplistic version check rejects B.

But a smarter API might detect that the fields don't conflict.

Conceptually:

Original
   |
   +---- Client A changes title
   |
   +---- Client B changes author
Enter fullscreen mode Exit fullscreen mode

The server can merge:

{
  "title": "Distributed Systems",
  "author": "Derek Mwale"
}
Enter fullscreen mode Exit fullscreen mode

Now MVCC becomes the foundation for more sophisticated conflict resolution.

This is particularly interesting for collaborative software.


14. MVCC and Collaborative Editing

Think about a document editor.

Ten users may be editing the same document.

A lock-based model might say:

User A editing
    |
    v
LOCK DOCUMENT
Enter fullscreen mode Exit fullscreen mode

Everyone else waits.

MVCC gives us a different model.

Each user can work against a version.

Version 10
   |
   +---- User A
   +---- User B
   +---- User C
Enter fullscreen mode Exit fullscreen mode

When users commit changes, the system compares versions.

This can be combined with:

operation logs
patches
conflict resolution
CRDTs
event sourcing
Enter fullscreen mode Exit fullscreen mode

MVCC isn't enough by itself to build Google Docs.

But it provides a powerful foundation for reasoning about concurrent state.


15. API Reads Need a Consistency Model

Once we expose concurrent data, we should ask:

What does a GET actually guarantee?

There are many possibilities.

READ_COMMITTED
REPEATABLE_READ
SERIALIZABLE
Enter fullscreen mode Exit fullscreen mode

Different APIs may choose different semantics.

For example:

GET /reports/monthly
Consistency: snapshot
Enter fullscreen mode Exit fullscreen mode

might mean:

Every part of this response should represent a consistent view.

Meanwhile:

GET /notifications
Enter fullscreen mode Exit fullscreen mode

may not require strict consistency.

This is important because consistency has a cost.

The API should not promise more than the underlying system needs to provide.


16. Long-Running APIs and MVCC

Imagine an API:

POST /reports/generate
Enter fullscreen mode Exit fullscreen mode

The report takes five minutes.

The database changes during those five minutes.

What data should the report contain?

Data at:

start time?
Enter fullscreen mode Exit fullscreen mode

Data at:

completion time?
Enter fullscreen mode Exit fullscreen mode

Or a consistent snapshot?

A snapshot-based approach can provide:

Report execution
       |
       v
Snapshot at T1
       |
       +--> Query A
       +--> Query B
       +--> Query C
       +--> Query D
Enter fullscreen mode Exit fullscreen mode

Every query sees the same logical database state.

Now your API can provide reproducible reports.

That's a powerful property.


17. MVCC and Event-Driven APIs

We can combine MVCC with events.

Suppose:

UPDATE users
Enter fullscreen mode Exit fullscreen mode

creates:

User version 18
Enter fullscreen mode Exit fullscreen mode

The system emits:

{
  "event": "user.updated",
  "user_id": 42,
  "version": 18
}
Enter fullscreen mode Exit fullscreen mode

Consumers can track versions.

Consumer version: 17
Event version: 18
Enter fullscreen mode Exit fullscreen mode

The consumer knows it is behind.

This can help with synchronization and recovery.

For example:

version 18
version 19
version 20
Enter fullscreen mode Exit fullscreen mode

If the consumer misses version 19, it can detect the gap.

That gives us another important distributed systems concept:

versioned state is easier to synchronize.


18. MVCC and Idempotency

MVCC also interacts beautifully with idempotency.

Suppose a request carries:

operation_id = 93821
Enter fullscreen mode Exit fullscreen mode

The API stores the operation result.

Then the client retries.

The server can check:

operation_id 93821
Enter fullscreen mode Exit fullscreen mode

and determine whether the operation has already been processed.

This can be wrapped inside a transaction.

Conceptually:

BEGIN

check idempotency key

if already processed:
    return stored result

perform operation

store result

COMMIT
Enter fullscreen mode Exit fullscreen mode

Now retries become much safer.

Again, we're connecting:

API design
+
transactions
+
MVCC
+
distributed failure
Enter fullscreen mode Exit fullscreen mode

19. MVCC Doesn't Eliminate Locks

This is an important misconception.

MVCC does not mean:

"There are no locks."

Databases still use locks internally for various operations.

MVCC mainly changes how reads and writes interact.

Readers can often see an appropriate version without blocking writers in the traditional way.

But there are still:

row locks
predicate locks
metadata locks
advisory locks
serialization conflicts
Enter fullscreen mode Exit fullscreen mode

The correct mental model isn't:

MVCC = lock-free database
Enter fullscreen mode Exit fullscreen mode

It's:

MVCC = version-based concurrency model
Enter fullscreen mode Exit fullscreen mode

That distinction matters.


20. MVCC and Serialization Failures

At higher isolation levels, the database may detect that concurrent transactions cannot safely coexist.

For example:

Transaction A
    |
    +---- reads X
    |
    +---- writes Y

Transaction B
    |
    +---- reads Y
    |
    +---- writes X
Enter fullscreen mode Exit fullscreen mode

The database may abort one transaction.

The API must be prepared for this.

It might receive an error such as:

serialization_failure
Enter fullscreen mode Exit fullscreen mode

The correct response may be:

retry transaction
Enter fullscreen mode Exit fullscreen mode

But only if the operation is safe to retry.

Again:

the database concurrency model leaks into API behavior.


21. Building a Version-Aware API in Rust

A simple Rust model could be:

struct Resource {
    id: i64,
    version: i64,
    name: String,
}
Enter fullscreen mode Exit fullscreen mode

An update request:

struct UpdateRequest {
    id: i64,
    expected_version: i64,
    name: String,
}
Enter fullscreen mode Exit fullscreen mode

The service logic:

async fn update_user(
    db: &Db,
    request: UpdateRequest,
) -> Result<User, ApiError> {
    let result = sqlx::query!(
        r#"
        UPDATE users
        SET name = $1,
            version = version + 1
        WHERE id = $2
          AND version = $3
        RETURNING id, name, version
        "#,
        request.name,
        request.id,
        request.expected_version
    )
    .fetch_optional(db)
    .await?;

    match result {
        Some(user) => Ok(user.into()),
        None => Err(ApiError::Conflict),
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice what's happening.

The API doesn't lock the user while someone edits it.

It doesn't care how long the client took.

It simply says:

Update this record if the world is still the version I observed.

That is a beautiful concurrency primitive.


22. MVCC Turns Time Into Data

This is perhaps the most interesting philosophical aspect.

Traditional CRUD encourages us to think:

User 42
Enter fullscreen mode Exit fullscreen mode

MVCC encourages us to think:

User 42 at version 10
User 42 at version 11
User 42 at version 12
Enter fullscreen mode Exit fullscreen mode

The resource becomes temporal.

It has history.

It has lineage.

It has a relationship with previous states.

And APIs can use that.

For example:

GET /users/42/history
Enter fullscreen mode Exit fullscreen mode

could expose:

{
  "versions": [
    {
      "version": 10,
      "name": "Derek"
    },
    {
      "version": 11,
      "name": "Derek Mwale"
    },
    {
      "version": 12,
      "name": "Derek Mwale",
      "email": "derek@example.com"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Now the API becomes partially temporal.


23. A Better API Architecture

A serious MVCC-aware API might look like:

                     Client
                        |
                        v
                +---------------+
                |   API Gateway |
                +---------------+
                        |
                        v
                +---------------+
                | Version Check |
                +---------------+
                        |
                        v
                +---------------+
                | Transaction   |
                | Manager       |
                +---------------+
                        |
                        v
                +---------------+
                | Service Layer |
                +---------------+
                        |
                        v
                +---------------+
                | MVCC Database |
                +---------------+
                        |
                        v
                +---------------+
                | Event Stream  |
                +---------------+
Enter fullscreen mode Exit fullscreen mode

The API can expose:

GET resource
PATCH resource with version
GET resource history
GET snapshot
POST transaction
Enter fullscreen mode Exit fullscreen mode

Not every system needs all of these.

But the architecture becomes much more explicit about concurrency.


24. The Strange Connection Between APIs and Databases

We often separate:

API design
Enter fullscreen mode Exit fullscreen mode

from:

database design
Enter fullscreen mode Exit fullscreen mode

But concurrency makes that separation blurry.

Consider:

PATCH /documents/42
Enter fullscreen mode Exit fullscreen mode

The API needs to answer:

What happens if another client changes the document between read and write?

That is a database question.

But the API must communicate the answer.

Therefore:

Database concurrency
        |
        v
Service semantics
        |
        v
API contract
Enter fullscreen mode Exit fullscreen mode

The API becomes the public face of the database's concurrency model.

That's why backend engineers need to understand both sides.


25. Don't Hide Concurrency

A common design mistake is pretending concurrency doesn't exist.

You expose:

PUT /users/42
Enter fullscreen mode Exit fullscreen mode

and simply overwrite the row.

It works beautifully in development.

Then production arrives.

Two browser tabs.

Three mobile clients.

A background worker.

An admin dashboard.

An integration.

Suddenly five independent systems can modify the same object.

Now you discover that:

PUT
Enter fullscreen mode Exit fullscreen mode

was secretly:

concurrent distributed write
Enter fullscreen mode Exit fullscreen mode

The concurrency was always there.

You simply weren't modeling it.


26. MVCC Gives APIs a Better Vocabulary

Instead of saying:

"The update failed."
Enter fullscreen mode Exit fullscreen mode

we can say:

"The resource changed since you last observed it."
Enter fullscreen mode Exit fullscreen mode

Instead of:

"Something went wrong."
Enter fullscreen mode Exit fullscreen mode

we can say:

"Your transaction conflicted with another committed transaction."
Enter fullscreen mode Exit fullscreen mode

Instead of:

"Try again."
Enter fullscreen mode Exit fullscreen mode

we can say:

"Retry using the latest version."
Enter fullscreen mode Exit fullscreen mode

This is much more meaningful.

The API communicates the actual state of the distributed interaction.


27. The Real Lesson

Building an API on top of MVCC isn't primarily about writing SQL.

It's about changing your mental model.

A resource isn't necessarily:

an object
Enter fullscreen mode Exit fullscreen mode

It can be:

an object evolving through time.
Enter fullscreen mode Exit fullscreen mode

A request isn't necessarily:

an instruction
Enter fullscreen mode Exit fullscreen mode

It can be:

an attempt to transition one version of reality into another.
Enter fullscreen mode Exit fullscreen mode

A conflict isn't necessarily:

an error
Enter fullscreen mode Exit fullscreen mode

It can be:

evidence that another actor changed the world first.
Enter fullscreen mode Exit fullscreen mode

And a transaction isn't merely:

BEGIN
COMMIT
Enter fullscreen mode Exit fullscreen mode

It is:

a temporary view of reality
+
a set of intended changes
+
a validation that those changes can safely become reality.
Enter fullscreen mode Exit fullscreen mode

That's a much deeper way to think about APIs.


Conclusion: Your API Is Already Concurrency Control

The moment an API has more than one client, concurrency exists.

You can ignore it.

You can hide it.

Or you can design for it.

MVCC gives us a powerful foundation.

It allows databases to maintain multiple versions of data, provide consistent transactional views, and coordinate concurrent access without forcing every reader to wait for every writer.

When we expose the right abstractions at the API layer — version tokens, ETags, conditional updates, snapshots, conflicts, idempotency keys, and transaction semantics — we can build APIs that acknowledge the reality of distributed state.

The key idea is simple:

Client reads version 10
        |
        v
Client works
        |
        v
Client attempts update
        |
        v
Is version still 10?
       / \
     YES  NO
      |    |
      v    v
   Commit Conflict
      |
      v
Version 11
Enter fullscreen mode Exit fullscreen mode

This is more than database engineering.

It's a philosophy of API design.

Don't pretend the world is static.

The world changes while your client is thinking.

Other clients are writing.

Workers are running.

Events are arriving.

Transactions are committing.

Caches are expiring.

Replicas are catching up.

Your API exists in the middle of all of that motion.

MVCC gives you a way to reason about that motion.

And once you start designing APIs around versions instead of pretending every request happens in isolation, something clicks:

Concurrency stops being an annoying edge case and becomes part of the API's language.

That's the real power of building an API on top of MVCC.

Top comments (0)