REST API Concurrency Control

Two people open the same record, edit different fields, and save a few seconds apart. The second save can silently erase the first change. This problem, called a lost update, appears in any API that many users share. Concurrency control keeps edits from overwriting each other.

The Shared Document Picture

Two colleagues download the same spreadsheet by email. Each fixes a few cells and sends the file back. Whoever replies last overwrites the work of the other. Shared online documents prevent this by checking versions. APIs apply the same idea.

The Lost Update

Time    User A                    User B                    Stored price
 1      reads price 100           reads price 100           100
 2      sets price 120 ── save ───────────────────────────→ 120
 3                                sets price 90 ── save ──→ 90

Result: the change from User A vanished without a warning.

Optimistic Concurrency

Optimistic control assumes conflicts happen rarely. Nobody locks the record. Each resource carries a version marker, and the server checks that marker on every update. A mismatch means someone else changed the record, and the server refuses the write.

ETag and If-Match

HTTP provides two headers for this job. The server returns an ETag with each read, which acts as a fingerprint of the current version. The client sends the same value in an If-Match header with its update.

GET /products/8
→ 200 OK
  ETag: "v7"
  { "id": "8", "price": 100 }

PUT /products/8
If-Match: "v7"
{ "price": 120 }
→ 200 OK
  ETag: "v8"

PUT /products/8
If-Match: "v7"           (User B still holds the old version)
{ "price": 90 }
→ 412 Precondition Failed

User B receives a clear signal. The record changed, so the app can reload the latest data and ask the user to review before saving again.

Requiring the Header

Some APIs insist on conditional updates for risky resources. A request without If-Match then receives 428 Precondition Required. This rule stops careless clients from overwriting data.

Choosing the Version Marker

MarkerHow It Works
Version numberAn integer that rises by one on every change
Last modified timeThe update timestamp, used with If-Unmodified-Since
Content hashA hash computed from the record fields

A version number works well for most databases. The update statement includes the version in its condition and increments it in the same step.

UPDATE products
SET price = 120, version = version + 1
WHERE id = 8 AND version = 7

When the statement changes zero rows, the version moved on, and the API returns 412.

Pessimistic Concurrency

Pessimistic control locks a record while one user edits it. Others wait or see a read-only copy. This approach prevents conflicts, and it risks stuck locks when a user closes the browser mid-edit. REST APIs favor the optimistic style, because HTTP requests are short and stateless.

Handling a Conflict in the Client

Save fails with 412
       │
       ▼
Reload latest version ──→ Show differences ──→ User merges ──→ Save again

Good apps show what changed and let the user choose. Silent retries bring the lost update problem back.

ETags and Caching

ETags help with caching as well. A client sends If-None-Match with a stored ETag on a GET request. The server returns 304 Not Modified with no body when nothing changed, which saves bandwidth.

Common Mistakes

Ignoring Concurrency on Updates

Most simple APIs let the last writer win. This choice looks harmless until two users edit the same invoice.

Weak ETags That Hide Changes

An ETag built from only the title misses edits to other fields. Base the marker on the whole resource state.

Retrying 412 Automatically

A blind retry with the same old version fails again. Fetch fresh data first.