19. Pattern: Optimistic Locking
The Optimistic Locking pattern prevents the “lost update” problem in RESTful APIs when multiple clients attempt to modify the same resource concurrently.
Instead of holding expensive pessimistic database locks during user edit sessions, this pattern uses standard HTTP precondition headers (If-Match or If-Unmodified-Since) alongside validator headers (ETag or Last-Modified) to verify that a resource has not changed since the client last retrieved it.
Note: Refer to the Cache Control Pattern if you are unfamiliar with HTTP cache controls and pre-conditions.
19.1. Overview
In distributed web applications, two clients often fetch the same resource simultaneously, modify it locally, and send back updates. Without concurrency control, the second update silently overwrites the first update.
HTTP provides built-in conditional mechanisms to handle optimistic concurrency:
- ETag &
If-Match(Strong / Entity Tag Variation): The server returns an opaque version identifier or content hash in theETagheader. The client sends this identifier back in theIf-Matchheader when executingPUT,PATCH, orDELETErequests. - Last-Modified &
If-Unmodified-Since(Timestamp Variation): The server returns an HTTP date timestamp in theLast-Modifiedheader. The client sends this timestamp back in theIf-Unmodified-Sinceheader.
Core HTTP Status Codes
412 Precondition Failed: Returned when the resource state on the server has changed since the client retrieved it (e.g., ETags do not match or the resource was modified after the timestamp).428 Precondition Required: Returned when the server mandates concurrency control for an endpoint, but the client omitted the required precondition header (If-MatchorIf-Unmodified-Since).
19.2. When to Use Optimistic Locking
Use Optimistic Locking when:
- Multiple users or clients can edit the same resource.
- e.g., Collaborative document editing, shared inventory management, or multi-user customer profile management.
- You must prevent lost updates.
- e.g., Client A changes a user’s address while Client B changes the same user’s phone number; without concurrency control, Client B’s write wipes out Client A’s address change.
- Read operations significantly outnumber write operations.
- Optimistic locking avoids the performance overhead and potential deadlocks associated with pessimistic database locks.
19.3. When NOT to Use Optimistic Locking
Avoid Optimistic Locking when:
- Write contention is extremely high.
- If dozens of clients consistently update the same record simultaneously, frequent
412 Precondition Failederrors will frustrate users. Use queueing, pessimistic locking, or event-sourcing instead.
- If dozens of clients consistently update the same record simultaneously, frequent
- The resource is append-only.
- Log entries, audit trails, and event streams do not suffer from lost updates because existing records are never modified.
- The endpoint performs an idempotent read (
GET,HEAD).- Concurrency checks are only applicable to state-modifying operations (
PUT,PATCH,DELETE).
- Concurrency checks are only applicable to state-modifying operations (
19.4. What the Pattern Looks Like
Below are detailed HTTP interaction flows showing two clients competing to update the same resource using both ETags and Timestamps, as well as the error flows for missing or mismatched preconditions.
Variation A: ETags & If-Match
1. Initial Retrieval (Client 1 & Client 2)
Both clients fetch the current state of a resource.
GET /products/prd_991 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Response
HTTP/1.1 200 OK
ETag: "v1-8f2b1a"
Content-Type: application/json
{
"id": "prd_991",
"name": "Wireless Mouse",
"stock": 50
}
2. Client 1 Successfully Updates Resource
Client 1 updates the stock count using If-Match: "v1-8f2b1a".
PATCH /products/prd_991 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
If-Match: "v1-8f2b1a"
Content-Type: application/json
{
"stock": 45
}
Response
The server accepts the update and returns a new ETag.
HTTP/1.1 200 OK
ETag: "v2-3c9d8e"
Content-Type: application/json
{
"id": "prd_991",
"name": "Wireless Mouse",
"stock": 45
}
3. Error Scenario A: Client 2 Fails Precondition (412 Precondition Failed)
Client 2 attempts to update the product using their stale ETag (v1-8f2b1a).
PATCH /products/prd_991 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
If-Match: "v1-8f2b1a"
Content-Type: application/json
{
"name": "Ergonomic Wireless Mouse"
}
Response
The server detects that the current ETag (v2-3c9d8e) does not match v1-8f2b1a and rejects the update.
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/precondition-failed",
"title": "Precondition Failed",
"status": 412,
"detail": "The resource has been modified by another process. Please retrieve the latest version before retrying."
}
4. Error Scenario B: Client Omits Header (428 Precondition Required)
A client attempts to update the resource without supplying an If-Match header on a protected resource.
PATCH /products/prd_991 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
{
"stock": 10
}
Response
HTTP/1.1 428 Precondition Required
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/precondition-required",
"title": "Precondition Required",
"status": 428,
"detail": "Updates to this resource require conditional concurrency control. Please supply an 'If-Match' header containing the current ETag."
}
Variation B: Timestamps (Last-Modified & If-Unmodified-Since)
1. Initial Retrieval
GET /articles/art_404 HTTP/1.1
Host: api.example.com
Response
HTTP/1.1 200 OK
Last-Modified: Wed, 29 Jul 2026 12:00:00 GMT
Content-Type: application/json
{
"id": "art_404",
"title": "Optimistic Locking Guide",
"status": "DRAFT"
}
2. Client 1 Update (Successful)
PATCH /articles/art_404 HTTP/1.1
Host: api.example.com
If-Unmodified-Since: Wed, 29 Jul 2026 12:00:00 GMT
Content-Type: application/json
{
"status": "PUBLISHED"
}
Response
HTTP/1.1 200 OK
Last-Modified: Wed, 29 Jul 2026 14:30:00 GMT
Content-Type: application/json
{
"id": "art_404",
"title": "Optimistic Locking Guide",
"status": "PUBLISHED"
}
3. Client 2 Stale Update (412 Precondition Failed)
PATCH /articles/art_404 HTTP/1.1
Host: api.example.com
If-Unmodified-Since: Wed, 29 Jul 2026 12:00:00 GMT
Content-Type: application/json
{
"title": "Updated Title"
}
Response
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
{
"type": "https://api.example.com/errors/precondition-failed",
"title": "Precondition Failed",
"status": 412,
"detail": "The resource was modified at 2026-07-29T14:30:00Z, which is newer than your requested timestamp."
}
19.5. Anti-Patterns to Avoid
1. Domain pollution with version properties in JSON bodies
{
"id": "prd_991",
"name": "Wireless Mouse",
"version": 3
}
- Why it’s bad: Embedding version numbers directly inside JSON request payloads mixes protocol-level concurrency controls into application domain schemas. Use HTTP standard headers (
If-Match/ETag) instead.
2. Silently ignoring missing precondition headers
- If an endpoint supports optimistic locking but does not enforce it, clients that omit
If-Matchwill silently overwrite concurrent changes. Mandate conditional requests by returning428 Precondition Required.
3. Using Last-Modified when sub-second precision is required
- The HTTP specification mandates that
Last-Modifieddates use 1-second resolution (Wed, 29 Jul 2026 12:00:00 GMT). If multiple updates can occur within the same second, timestamp comparisons can fail to detect changes. UseETag(hashes or version counters) for high-frequency resources.
4. Using Weak ETags (W/"...") for state mutation checks
- Weak ETags represent semantic equivalence rather than byte-for-byte identity. When validating preconditions for
PUTorPATCHupdates, use strong ETags (e.g.,"v1-8f2b1a"without theW/prefix) to guarantee exact match states.
19.6. OpenAPI Examples
A complete OpenAPI 3.0.3 specification illustrating how to document precondition headers (If-Match, If-Unmodified-Since), validator headers (ETag, Last-Modified), and error status codes (412, 428).
openapi: 3.0.3
info:
title: Product Catalog API - Optimistic Locking Example
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/products/{productId}:
get:
summary: Retrieve product details
description: Returns product details alongside an ETag and Last-Modified header for concurrency control.
tags: [Products]
parameters:
- in: path
name: productId
required: true
schema:
type: string
example: prd_991
responses:
'200':
description: Product retrieved successfully
headers:
ETag:
schema:
type: string
example: '"v1-8f2b1a"'
description: Current entity tag for concurrency control.
Last-Modified:
schema:
type: string
example: "Wed, 29 Jul 2026 12:00:00 GMT"
description: HTTP date of the last modification.
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
patch:
summary: Update product details
description: Requires an 'If-Match' header to prevent lost updates.
tags: [Products]
parameters:
- in: path
name: productId
required: true
schema:
type: string
example: prd_991
- in: header
name: If-Match
required: false
schema:
type: string
example: '"v1-8f2b1a"'
description: The ETag string previously returned in the GET response.
- in: header
name: If-Unmodified-Since
required: false
schema:
type: string
example: "Wed, 29 Jul 2026 12:00:00 GMT"
description: Alternative timestamp concurrency validator.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ProductUpdateRequest'
responses:
'200':
description: Product updated successfully
headers:
ETag:
schema:
type: string
example: '"v2-3c9d8e"'
content:
application/json:
schema:
$ref: '#/components/schemas/Product'
'412':
description: Precondition Failed - Resource was updated by another process
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
'428':
description: Precondition Required - Missing required If-Match header
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
components:
schemas:
Product:
type: object
properties:
id:
type: string
example: prd_991
name:
type: string
example: Wireless Mouse
stock:
type: integer
example: 45
ProductUpdateRequest:
type: object
properties:
name:
type: string
stock:
type: integer
ProblemDetails:
type: object
properties:
type:
type: string
example: "https://api.example.com/errors/precondition-failed"
title:
type: string
example: "Precondition Failed"
status:
type: integer
example: 412
detail:
type: string
example: "The resource has been modified by another process."
19.7. Visualizing Optimistic Locking (Mermaid Diagram)
sequenceDiagram
autonumber
actor Client1 as Client 1
actor Client2 as Client 2
participant API as API Server
participant DB as Database
Note over Client1,DB: Phase 1: Concurrent Reads
Client1->>API: GET /products/prd_991
API->>DB: Query product prd_991
DB-->>API: Return data (version: "v1-8f2b1a")
API-->>Client1: 200 OK (ETag: "v1-8f2b1a", stock: 50)
Client2->>API: GET /products/prd_991
API->>DB: Query product prd_991
DB-->>API: Return data (version: "v1-8f2b1a")
API-->>Client2: 200 OK (ETag: "v1-8f2b1a", stock: 50)
Note over Client1,DB: Phase 2: Client 1 Updates First
Client1->>API: PATCH /products/prd_991<br/>Header: If-Match: "v1-8f2b1a"<br/>Body: { stock: 45 }
API->>DB: Verify DB version == "v1-8f2b1a" & Update
DB-->>API: Success (New version: "v2-3c9d8e")
API-->>Client1: 200 OK (ETag: "v2-3c9d8e", stock: 45)
Note over Client2,DB: Phase 3: Client 2 Error Scenarios
rect rgb(255, 240, 240)
Note over Client2,API: Scenario A: Missing Precondition Header
Client2->>API: PATCH /products/prd_991 (No If-Match header)
API-->>Client2: 428 Precondition Required
end
rect rgb(255, 230, 230)
Note over Client2,API: Scenario B: Stale Precondition Header
Client2->>API: PATCH /products/prd_991<br/>Header: If-Match: "v1-8f2b1a"
API->>DB: Verify DB version == "v1-8f2b1a"
DB-->>API: Match Failed! Current DB version is "v2-3c9d8e"
API-->>Client2: 412 Precondition Failed
end