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:

  1. ETag & If-Match (Strong / Entity Tag Variation): The server returns an opaque version identifier or content hash in the ETag header. The client sends this identifier back in the If-Match header when executing PUT, PATCH, or DELETE requests.
  2. Last-Modified & If-Unmodified-Since (Timestamp Variation): The server returns an HTTP date timestamp in the Last-Modified header. The client sends this timestamp back in the If-Unmodified-Since header.

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-Match or If-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 Failed errors will frustrate users. Use queueing, pessimistic locking, or event-sourcing instead.
  • 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).

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-Match will silently overwrite concurrent changes. Mandate conditional requests by returning 428 Precondition Required.

3. Using Last-Modified when sub-second precision is required

  • The HTTP specification mandates that Last-Modified dates 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. Use ETag (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 PUT or PATCH updates, use strong ETags (e.g., "v1-8f2b1a" without the W/ 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