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
| Marker | How It Works |
|---|---|
| Version number | An integer that rises by one on every change |
| Last modified time | The update timestamp, used with If-Unmodified-Since |
| Content hash | A 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.
