20 Pattern: Pessimistic Locking

The Pessimistic Locking pattern allows a client to explicitly acquire an exclusive lock on a resource for a specified duration before modifying it.

Using functional action endpoints (e.g., POST /resources/{id}/lock and POST /resources/{id}/unlock), the API issues a temporary lock token and lease time (TTL). While locked, all write requests (PUT, PATCH, DELETE) from consumers other than the lock owner are rejected.

Important Architectural Note: In distributed web and RESTful architectures, Optimistic Locking using HTTP precondition headers (If-Match / ETag) is almost always preferred over Pessimistic Locking. Pessimistic locking introduces statefulness, potential deadlocks, orphan lock management, and scaling bottlenecks. Only use pessimistic locking when optimistic conflict resolution is unacceptably expensive or impossible.

20.1. Overview

Pessimistic locking assumes concurrent updates will conflict and proactively blocks other consumers from making modifications while a critical operation or human workflow is underway.

Because REST is inherently stateless, pessimistic locking over HTTP relies on the Functional Resource Pattern (RPC-style action endpoints) to manage lock lifecycles:

  1. Acquire Lock (POST /resources/{id}/lock): The client requests an exclusive lease for a specific number of seconds (ttlSeconds). The server responds with a unique lockToken and expiration timestamp.
  2. Execute Protected Operation (PATCH / PUT): The lock owner includes the Lock-Token header in subsequent modification requests. The server verifies token ownership and allows the update.
  3. Reject Unauthenticated / Unlocked Attempts: Any request from another consumer—or requests missing the valid Lock-Token header—are rejected with 423 Locked or 409 Conflict.
  4. Release Lock (POST /resources/{id}/unlock): The owner explicitly releases the lock when finished, or the server automatically invalidates it when the TTL expires.

20.2. When to Use Pessimistic Locking

Use Pessimistic Locking only when:

  • Conflict resolution in an optimistic model is too costly.
    • e.g., Holding a concert ticket seat or flight selection for 5 minutes during a multi-step checkout process where mid-checkout conflicts ruin the user experience.
  • Physical or real-world operations are bound to the lock.
    • e.g., Controlling physical hardware, industrial robotics, or single-access IoT devices where concurrent commands cause physical damage.
  • Long-running human editing sessions on complex documents.
    • e.g., Collaborative CAD modeling or legacy enterprise data forms where manual merge resolution is impractical.

20.3. When NOT to Use Pessimistic Locking

Avoid Pessimistic Locking (and use Optimistic Locking) when:

  • Building scalable, high-throughput microservices or web/mobile APIs.
    • Holding locks across HTTP requests forces servers to track state and severely limits horizontal scaling.
  • Read-to-write ratios are high.
    • Standard CRUD operations should never hold pessimistic locks. Use If-Match with ETags to detect collision at the moment of update.
  • Clients cannot guarantee clean disconnects.
    • Mobile applications on flaky networks frequently drop connections. Unreleased locks starve other consumers until TTL timeouts expire.

20.4. What the Pattern Looks Like

Below are detailed HTTP interaction flows showing how a client acquires a lock, performs an update, blocks concurrent clients, and unlocks the resource.


1. Client 1 Acquires Lock (Functional Endpoint)

Client 1 requests a 300-second (5-minute) lease on an order resource.

Request

POST /orders/ord_99182/lock HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{
  "ttlSeconds": 300,
  "reason": "Seat reservation checkout"
}

Response

The server returns a unique lock token and expiration timestamp.

HTTP/1.1 201 Created
Lock-Token: lk_tok_88291a
Content-Type: application/json

{
  "orderId": "ord_99182",
  "lockToken": "lk_tok_88291a",
  "lockedBy": "usr_client_1",
  "expiresAt": "2026-08-03T14:35:00Z",
  "ttlRemainingSeconds": 300
}

2. Client 1 Successfully Updates Resource (Owner)

Client 1 passes the Lock-Token header to execute the update.

Request

PATCH /orders/ord_99182 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Lock-Token: lk_tok_88291a
Content-Type: application/json

{
  "seatNumber": "12A",
  "status": "CONFIRMED"
}

Response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": "ord_99182",
  "seatNumber": "12A",
  "status": "CONFIRMED",
  "updatedAt": "2026-08-03T14:31:00Z"
}

3. Client 2 Attempted Update (Rejected with 423 Locked)

Client 2 attempts to modify the same resource without the valid lock token.

Request

PATCH /orders/ord_99182 HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json

{
  "seatNumber": "12A"
}

Response

The server rejects the update because the resource is locked by Client 1.

HTTP/1.1 423 Locked
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/resource-locked",
  "title": "Resource Locked",
  "status": 423,
  "detail": "The order 'ord_99182' is currently locked by another consumer until 2026-08-03T14:35:00Z.",
  "lockedUntil": "2026-08-03T14:35:00Z"
}

4. Client 1 Releases Lock (Functional Endpoint)

Once finished, Client 1 explicitly releases the lock.

Request

POST /orders/ord_99182/unlock HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOi...
Lock-Token: lk_tok_88291a

Response

HTTP/1.1 204 No Content

20.5. Anti-Patterns to Avoid

1. Indefinite locks without TTL expiration

  • Creating locks that never expire means a single client crash or network disconnection permanently freezes the resource until manual database intervention occurs. Always enforce a hard ttlSeconds cap server-side.

2. Using pessimistic locking when optimistic locking works

  • Introducing lock/unlock endpoints for standard data editing burdens clients with state management and invites deadlocks. Always prefer Optimistic Locking (If-Match / ETag) unless business rules strictly forbid conflict resolution.

3. Passing lock tokens in request JSON bodies

  • Mixing protocol control tokens into domain payload objects pollutes application models. Pass lock tokens in custom HTTP headers (e.g., Lock-Token or X-Lock-Token).

4. Non-atomic lock acquisitions

  • Checking if a resource is locked in one database query and inserting the lock record in another introduces race conditions. Ensure lock creation is executed in a single atomic database operation or distributed lock manager (e.g., Redis SET NX EX).

20.6. OpenAPI Example

A complete OpenAPI 3.0.3 specification illustrating how to document functional lock/unlock endpoints, the Lock-Token header, and the 423 Locked error response.

openapi: 3.0.3
info:
  title: Order Management API - Pessimistic Locking Example
  version: 1.0.0
servers:
  - url: https://api.example.com

paths:
  /orders/{orderId}/lock:
    post:
      summary: Acquire exclusive lock on an order
      description: Issues a temporary lock token and lease time. Blocks write access for other consumers.
      tags: [Order Locking]
      parameters:
        - in: path
          name: orderId
          required: true
          schema:
            type: string
            example: ord_99182
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - ttlSeconds
              properties:
                ttlSeconds:
                  type: integer
                  default: 300
                  maximum: 900
                  example: 300
                reason:
                  type: string
                  example: "Seat reservation checkout"
      responses:
        '201':
          description: Lock successfully acquired
          headers:
            Lock-Token:
              schema:
                type: string
                example: lk_tok_88291a
              description: Token required in subsequent update requests.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LockResponse'
        '409':
          description: Resource is already locked by another client
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /orders/{orderId}/unlock:
    post:
      summary: Release an existing lock
      description: Releases the exclusive lock using the issued Lock-Token.
      tags: [Order Locking]
      parameters:
        - in: path
          name: orderId
          required: true
          schema:
            type: string
            example: ord_99182
        - in: header
          name: Lock-Token
          required: true
          schema:
            type: string
            example: lk_tok_88291a
      responses:
        '204':
          description: Lock released successfully
        '403':
          description: Invalid or expired lock token

  /orders/{orderId}:
    patch:
      summary: Update order (requires Lock-Token if locked)
      tags: [Orders]
      parameters:
        - in: path
          name: orderId
          required: true
          schema:
            type: string
            example: ord_99182
        - in: header
          name: Lock-Token
          required: false
          schema:
            type: string
            example: lk_tok_88291a
          description: Required if the order is currently locked.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                seatNumber:
                  type: string
                  example: "12A"
                status:
                  type: string
                  example: "CONFIRMED"
      responses:
        '200':
          description: Order updated successfully
        '423':
          description: Resource Locked - Order is locked by another client
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

components:
  schemas:
    LockResponse:
      type: object
      properties:
        orderId:
          type: string
          example: ord_99182
        lockToken:
          type: string
          example: lk_tok_88291a
        lockedBy:
          type: string
          example: usr_client_1
        expiresAt:
          type: string
          format: date-time
          example: "2026-08-03T14:35:00Z"
        ttlRemainingSeconds:
          type: integer
          example: 300

    ProblemDetails:
      type: object
      properties:
        type:
          type: string
          example: "https://api.example.com/errors/resource-locked"
        title:
          type: string
          example: "Resource Locked"
        status:
          type: integer
          example: 423
        detail:
          type: string
          example: "The order is currently locked by another consumer."

20.7. Visualizing Pessimistic Locking (Mermaid Diagram)

sequenceDiagram
    autonumber
    actor Client1 as Client 1 (Lock Owner)
    actor Client2 as Client 2 (Other Consumer)
    participant API as API Gateway / Server
    participant LockMgr as Distributed Lock Manager
    participant DB as Database

    Note over Client1,DB: Phase 1: Client 1 Acquires Lock
    Client1->>API: POST /orders/ord_99182/lock { ttlSeconds: 300 }
    API->>LockMgr: Acquire lock for ord_99182 (TTL: 300s)
    LockMgr-->>API: Lock granted (Token: "lk_tok_88291a")
    API-->>Client1: 201 Created (Lock-Token: "lk_tok_88291a", expiresAt)

    Note over Client1,DB: Phase 2: Client 2 Attempt Blocked
    Client2->>API: PATCH /orders/ord_99182 (No Lock-Token)
    API->>LockMgr: Check lock status for ord_99182
    LockMgr-->>API: Active Lock Held by Client 1!
    API-->>Client2: 423 Locked ("Resource is locked by another consumer")

    Note over Client1,DB: Phase 3: Client 1 Updates Resource
    Client1->>API: PATCH /orders/ord_99182<br/>Header: Lock-Token: "lk_tok_88291a"
    API->>LockMgr: Validate Lock-Token "lk_tok_88291a"
    LockMgr-->>API: Token Valid
    API->>DB: UPDATE orders SET seat_number = '12A' WHERE id = 'ord_99182'
    DB-->>API: Update Success
    API-->>Client1: 200 OK (Updated Order)

    Note over Client1,DB: Phase 4: Client 1 Releases Lock
    Client1->>API: POST /orders/ord_99182/unlock<br/>Header: Lock-Token: "lk_tok_88291a"
    API->>LockMgr: Delete lock for ord_99182
    LockMgr-->>API: Lock Released
    API-->>Client1: 204 No Content