LearnFrenzy
Sign in Take a free test

Advanced HTTP Patterns · Lesson 20 of 30

Optimistic Locking with ETag and If-Match

4 min read Updated Nov 8, 2026 Saurabh Samir
On this page
  1. Implementation
  2. In the database
  3. Practice
  4. Summary

Two support agents open the same ticket. Asha changes the priority; a minute later Ravi changes the status and saves – and his PUT silently overwrites Asha's priority with the old value he loaded. This is the lost update problem, and it happens in every multi-user system. HTTP solves it elegantly with conditional requests: "save this only if the resource is still the version I read".

In this lesson

  • The lost update problem
  • Version ETags and If-Match
  • 412 Precondition Failed and 428 Precondition Required
  • What clients do on a conflict

Lost update vs optimistic locking

  1. Both readAsha and Ravi GET ticket 42 – version 1
  2. Asha savesPUT with If-Match: "v1" → 200, ticket is now version 2
  3. Ravi savesPUT with If-Match: "v1" → 412 Precondition Failed, nothing overwritten
  4. Ravi recoversre-reads version 2, re-applies his change, saves with If-Match: "v2"

Implementation

// server.js
import express from 'express';

const app = express();
app.use(express.json());
app.set('etag', false);

const tickets = new Map([[42, { id: 42, subject: 'Login fails', priority: 'low', status: 'open', version: 1 }]]);
const etag = (t) => `"v${t.version}"`;

app.get('/api/tickets/:id', (req, res) => {
  const t = tickets.get(Number(req.params.id));
  if (!t) return res.status(404).json({ error: 'Not found' });
  res.set('ETag', etag(t)).json(t);
});

app.patch('/api/tickets/:id', (req, res) => {
  const t = tickets.get(Number(req.params.id));
  if (!t) return res.status(404).json({ error: 'Not found' });
  const ifMatch = req.get('If-Match');
  if (!ifMatch) {
    return res.status(428).json({ error: 'Send If-Match with the ETag you last read' });       // require the precondition
  }
  if (ifMatch !== etag(t)) {
    return res.status(412).set('ETag', etag(t)).json({ error: 'The ticket was changed by someone else', currentVersion: t.version });
  }
  const { priority, status } = req.body;
  const updated = { ...t, ...(priority && { priority }), ...(status && { status }), version: t.version + 1 };
  tickets.set(t.id, updated);
  res.set('ETag', etag(updated)).json(updated);
});

app.listen(process.env.PORT ?? 3000);

Asha and Ravi editing the same ticket

$ curl http://localhost:3000/api/tickets/42
HTTP/1.1 200 OK
ETag: "v1"
Content-Type: application/json; charset=utf-8

{
  "id": 42,
  "subject": "Login fails",
  "priority": "low",
  "status": "open",
  "version": 1
}

$ curl -X PATCH http://localhost:3000/api/tickets/42 -H 'Content-Type: application/json' -H 'If-Match: "v1"' -d '{"priority":"high"}'
HTTP/1.1 200 OK
ETag: "v2"
Content-Type: application/json; charset=utf-8

{
  "id": 42,
  "subject": "Login fails",
  "priority": "high",
  "status": "open",
  "version": 2
}

$ curl -X PATCH http://localhost:3000/api/tickets/42 -H 'Content-Type: application/json' -H 'If-Match: "v1"' -d '{"status":"pending"}'
HTTP/1.1 412 Precondition Failed
ETag: "v2"
Content-Type: application/json; charset=utf-8

{
  "error": "The ticket was changed by someone else",
  "currentVersion": 2
}

$ curl -X PATCH http://localhost:3000/api/tickets/42 -H 'Content-Type: application/json' -d '{"status":"pending"}'
HTTP/1.1 428 Precondition Required
Content-Type: application/json; charset=utf-8

{
  "error": "Send If-Match with the ETag you last read"
}

$ curl -X PATCH http://localhost:3000/api/tickets/42 -H 'Content-Type: application/json' -H 'If-Match: "v2"' -d '{"status":"pending"}'
HTTP/1.1 200 OK
ETag: "v3"
Content-Type: application/json; charset=utf-8

{
  "id": 42,
  "subject": "Login fails",
  "priority": "high",
  "status": "pending",
  "version": 3
}
  • Asha's save matched version 1 and produced version 2.
  • Ravi's save still said If-Match: "v1", so it was rejected with 412 – Asha's priority survived. The response tells him the current ETag.
  • A save without If-Match got 428 Precondition Required: the API refuses blind overwrites.
  • After re-reading, Ravi's save with "v2" worked, and the ticket keeps both changes.

In the database

-- the same check, atomically, in SQL
UPDATE tickets
   SET status = 'pending', version = version + 1
 WHERE id = 42 AND version = 1;      -- 0 rows updated → someone else changed it → 412

Checking and updating in one statement avoids a race between "read the version" and "write". Most ORMs support this as a version column (JPA @Version, Prisma/Drizzle with a where on version).

Optimistic vs pessimistic locking

Pessimistic locking ("check out" a record so nobody else can edit) doesn't fit stateless HTTP – locks get stuck when clients disappear. Optimistic locking assumes conflicts are rare, lets everyone work, and detects conflicts at save time. It's the standard for REST APIs, used by Azure, Google Cloud and Salesforce APIs.

Practice

Try it yourself

  1. Your frontend gets a 412 when saving a form. What should it do?

    Show answer

    Fetch the latest version, show the user what changed (or merge automatically if the changes touch different fields), and let them save again with the new ETag. Never just retry with the new ETag automatically – that would reintroduce the lost update.

Quick check

Which header makes a write conditional?

If-Match with the ETag the client last read.

What does 412 mean?

The precondition failed – the resource changed since the client read it.

When do you return 428?

When your API requires a conditional request and the client didn't send one.

Summary

  • Concurrent edits cause lost updates unless writes are conditional.
  • Return a version ETag; require If-Match on PUT/PATCH; answer mismatches with 412 and missing ones with 428.
  • Do the version check and update atomically in the database.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular