JSON Concurrent Updates: JSON Patch, Optimistic Locking, ETags & CRDTs

Last updated:

Concurrent JSON updates require strategies to prevent lost writes when multiple clients modify the same resource simultaneously. Three patterns cover most scenarios: JSON Patch (RFC 6902) for atomic field-level operations, optimistic locking with ETags for conflict detection, and CRDTs for collaborative real-time editing. JSON Patch sends an array of operations — [{"op":"replace","path":"/status","value":"active"}] — applied atomically server-side, making concurrent updates predictable. ETag-based optimistic locking returns a version token with each GET; PATCH requests include If-Match: "v5" and receive 412 Precondition Failed if another client modified the resource first. JSON Merge Patch (RFC 7396) is simpler — send only changed fields, null to delete — but lacks atomic operation semantics. CRDTs (Automerge, Yjs) allow multiple clients to edit JSON simultaneously with automatic conflict-free merging. This guide covers JSON Patch operations, ETag optimistic locking, JSON Merge Patch, PostgreSQL JSONB versioned updates, and CRDT-based collaborative JSON.

JSON Patch (RFC 6902): Atomic Field-Level Operations

JSON Patch represents document changes as an array of operation objects. Each operation specifies an op type, a path (RFC 6901 JSON Pointer), and optionally a value. The server applies all operations atomically — if any operation fails, none are applied. This makes JSON Patch safe for concurrent systems: a patch that sets /status to "active" does not disturb /priority or any other field, and two clients patching non-overlapping paths will not conflict.

import jsonpatch from 'fast-json-patch'

// ── The 6 JSON Patch operation types ──────────────────────────────
// All operations use RFC 6901 JSON Pointer paths (/field/subfield/0)

const operations: jsonpatch.Operation[] = [
  // replace — change an existing field's value
  { op: 'replace', path: '/status',      value: 'active' },

  // add — insert a field or array element
  { op: 'add',     path: '/tags/-',      value: 'urgent' }, // append to array
  { op: 'add',     path: '/meta/score',  value: 42 },       // new nested field

  // remove — delete a field or array element
  { op: 'remove',  path: '/deleted' },
  { op: 'remove',  path: '/items/2' },  // remove array element at index 2

  // move — relocate a value (atomic rename)
  { op: 'move',    from: '/draft',       path: '/published' },

  // copy — duplicate a value to another path
  { op: 'copy',    from: '/template',    path: '/newDoc' },

  // test — assert a value before applying changes (optimistic concurrency)
  { op: 'test',    path: '/version',     value: 5 },
]

// ── Apply patch to a document ─────────────────────────────────────
const document = {
  status: 'draft',
  version: 5,
  tags: ['backend'],
  items: ['a', 'b', 'c'],
  deleted: true,
  meta: {},
}

// fast-json-patch mutates a deep clone when applyPatch is used
const result = jsonpatch.applyPatch(
  jsonpatch.deepClone(document),
  operations,
  /* validate */ true,
  /* mutate */ false,
).newDocument

// result.status === 'active'
// result.tags   === ['backend', 'urgent']
// result.items  === ['a', 'b']            (index 2 'c' removed)
// 'deleted' key gone, meta.score === 42

// ── Sending a JSON Patch via HTTP PATCH ───────────────────────────
async function patchResource(
  url: string,
  etag: string,
  patch: jsonpatch.Operation[],
) {
  const res = await fetch(url, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json-patch+json', // RFC 6902 MIME type
      'If-Match': etag,  // optimistic locking — server returns 412 if stale
    },
    body: JSON.stringify(patch),
  })

  if (res.status === 412) throw new Error('Precondition Failed — re-fetch and retry')
  if (res.status === 409) throw new Error('Conflict — test operation failed')
  if (!res.ok) throw new Error(`Patch failed: ${res.status}`)

  return res.json()
}

// ── Server-side: apply and persist ───────────────────────────────
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
  const ifMatch = req.headers.get('if-match')
  const patch   = await req.json() as jsonpatch.Operation[]

  // 1. Load document with current version
  const row = await db.query('SELECT data, version FROM docs WHERE id = $1', [params.id])
  if (!row) return NextResponse.json({ error: 'Not Found' }, { status: 404 })

  // 2. Optimistic locking check
  const currentETag = `"${row.version}"`
  if (ifMatch && ifMatch !== currentETag) {
    return NextResponse.json({ error: 'Precondition Failed' }, { status: 412 })
  }

  // 3. Validate and apply patch
  const errors = jsonpatch.validate(patch, row.data)
  if (errors.length) {
    return NextResponse.json({ error: 'Invalid patch', errors }, { status: 400 })
  }
  const { newDocument } = jsonpatch.applyPatch(row.data, patch, true, false)

  // 4. Persist with version increment
  const updated = await db.query(
    'UPDATE docs SET data = $1, version = version + 1 WHERE id = $2 AND version = $3 RETURNING *',
    [newDocument, params.id, row.version],
  )
  if (updated.rowCount === 0) {
    // Concurrent write won the race — return 412 even without If-Match
    return NextResponse.json({ error: 'Concurrent modification' }, { status: 412 })
  }

  const newVersion = updated.rows[0].version
  return NextResponse.json(updated.rows[0].data, {
    headers: { ETag: `"${newVersion}"` },
  })
}

The test operation is JSON Patch's built-in optimistic locking primitive. Including { "op": "test", "path": "/version", "value": 5 } as the first operation in a patch causes the server to abort the entire patch with 409 Conflict if version is not exactly 5. This is useful when you want the patch document itself to carry the version assertion rather than relying on If-Match headers — for example, in WebSocket-based protocols where HTTP headers are not available. Use the MIME type application/json-patch+json for the Content-Type header of PATCH requests carrying JSON Patch arrays.

JSON Merge Patch (RFC 7396): Partial Updates with Null Deletes

JSON Merge Patch is the simpler alternative: the patch body is a partial JSON object where present keys override the resource's keys and keys set to null are deleted. No operation vocabulary, no JSON Pointer paths — just a subset of the JSON document. This makes Merge Patch easy to construct by hand and intuitive for simple field updates, but it cannot express array element operations or atomic multi-step transformations.

// ── JSON Merge Patch semantics ────────────────────────────────────
// RFC 7396: MIME type is application/merge-patch+json

// Original resource:
const original = {
  title:    'Draft Post',
  status:   'draft',
  tags:     ['backend', 'api'],
  deleted:  false,
  priority: 3,
}

// Merge patch — only changed fields + null for deletes:
const mergePatch = {
  title:   'Published Post', // override title
  status:  'published',      // override status
  deleted: null,             // DELETE this key from the resource
  // tags and priority are absent — left unchanged
}

// ── Applying merge patch in JavaScript ───────────────────────────
function applyMergePatch<T extends object>(target: T, patch: Partial<T>): T {
  const result = { ...target }
  for (const [key, value] of Object.entries(patch)) {
    if (value === null) {
      delete (result as Record<string, unknown>)[key]
    } else if (typeof value === 'object' && !Array.isArray(value)) {
      // Recurse into nested objects
      (result as Record<string, unknown>)[key] = applyMergePatch(
        (target as Record<string, unknown>)[key] as object ?? {},
        value as object,
      )
    } else {
      (result as Record<string, unknown>)[key] = value
    }
  }
  return result
}

const updated = applyMergePatch(original, mergePatch)
// updated.title   === 'Published Post'
// updated.status  === 'published'
// 'deleted' key is gone
// updated.tags    === ['backend', 'api']  (unchanged)
// updated.priority === 3                  (unchanged)

// ── Sending Merge Patch via HTTP PATCH ───────────────────────────
async function mergePatchResource(url: string, etag: string, patch: object) {
  const res = await fetch(url, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/merge-patch+json', // RFC 7396 MIME type
      'If-Match': etag,
    },
    body: JSON.stringify(patch),
  })
  if (res.status === 412) throw new Error('Concurrent modification — re-fetch and retry')
  if (!res.ok) throw new Error(`Merge patch failed: ${res.status}`)
  return res.json()
}

// ── Merge Patch limitations compared to JSON Patch ───────────────
// CANNOT express: remove array element at index 2
//   ✗ Merge Patch: { "items": [0, 1] }  — replaces entire array (overwrites all!)
//   ✓ JSON Patch:  [{ "op": "remove", "path": "/items/2" }]

// CANNOT set a field to null (null means delete in Merge Patch):
//   ✗ Merge Patch: { "nullable": null }  — deletes the field
//   ✓ JSON Patch:  [{ "op": "replace", "path": "/nullable", "value": null }]

// PostgreSQL server-side Merge Patch application using jsonb concatenation:
// UPDATE docs
//   SET data = (data || $1::jsonb) - ARRAY(
//       SELECT key FROM jsonb_each($1::jsonb) WHERE value = 'null'::jsonb
//     )
//   WHERE id = $2 AND version = $3
// This uses || for merge and removes null keys in one SQL expression.

Use application/merge-patch+json as the Content-Type for Merge Patch requests, and application/json-patch+json for JSON Patch requests — clients and servers can inspect the content type to choose the appropriate application logic. In REST APIs that support both, route them to different handler functions based on the content type. Merge Patch is appropriate for most CRUD update endpoints where arrays are treated as whole values; switch to JSON Patch when clients need to manipulate individual array elements.

Optimistic Locking with ETag and If-Match Headers

ETag-based optimistic locking is the standard HTTP mechanism for preventing lost updates without server-side pessimistic locks. The server sends an ETag header with each GET response; clients send that value back as If-Match in subsequent modification requests. If the resource changed since the client's GET, the server returns 412 Precondition Failed — the client re-fetches and retries rather than silently overwriting the change.

// ── Server: GET with ETag ─────────────────────────────────────────
import crypto from 'node:crypto'

function generateETag(data: unknown): string {
  // Strong ETag: hash of the serialized resource
  const hash = crypto.createHash('sha256')
    .update(JSON.stringify(data))
    .digest('hex')
    .slice(0, 16)
  return `"${hash}"` // ETag must be quoted string per RFC 7232
}

// Alternative: use a monotonic version integer stored in the database
// ETag: "42"  (simpler, no hash computation needed)

// ── GET handler ──────────────────────────────────────────────────
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const row = await db.query('SELECT data, version FROM docs WHERE id = $1', [params.id])
  if (!row) return Response.json({ error: 'Not Found' }, { status: 404 })

  const etag = `"${row.version}"`

  // Conditional GET support — return 304 if resource unchanged
  const ifNoneMatch = req.headers.get('if-none-match')
  if (ifNoneMatch === etag) {
    return new Response(null, { status: 304, headers: { ETag: etag } })
  }

  return Response.json(row.data, {
    headers: {
      ETag:          etag,
      'Cache-Control': 'no-cache', // require revalidation
    },
  })
}

// ── PATCH handler with If-Match check ────────────────────────────
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
  const ifMatch = req.headers.get('if-match')

  // Require If-Match — reject unsafe modifications without a version assertion
  if (!ifMatch) {
    return Response.json(
      { error: 'If-Match header required', type: '/errors/precondition-required' },
      { status: 428 }, // 428 Precondition Required
    )
  }

  const patch = await req.json()

  // Atomic version check + update in one SQL statement
  const expectedVersion = parseInt(ifMatch.replace(/"/g, ''), 10)

  const updated = await db.query(`
    UPDATE docs
      SET data    = jsonb_set(data, '{status}', $1::jsonb),
          version = version + 1
    WHERE id = $2 AND version = $3
    RETURNING data, version
  `, [JSON.stringify(patch.value), params.id, expectedVersion])

  if (updated.rowCount === 0) {
    // Either the document doesn't exist or version mismatch
    const exists = await db.query('SELECT 1 FROM docs WHERE id = $1', [params.id])
    if (!exists.rowCount) return Response.json({ error: 'Not Found' }, { status: 404 })
    return Response.json(
      { error: 'Precondition Failed', type: '/errors/concurrent-modification' },
      { status: 412 },
    )
  }

  const { data, version } = updated.rows[0]
  return Response.json(data, {
    headers: { ETag: `"${version}"` },
  })
}

// ── Client: full optimistic locking flow ─────────────────────────
async function updateWithRetry(
  url: string,
  applyChanges: (doc: unknown) => unknown,
  maxRetries = 3,
) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // 1. Fetch current state
    const getRes = await fetch(url, { headers: { 'Cache-Control': 'no-cache' } })
    const etag   = getRes.headers.get('etag') ?? ''
    const doc    = await getRes.json()

    // 2. Compute desired changes
    const patched = applyChanges(doc)

    // 3. Attempt update with If-Match
    const patchRes = await fetch(url, {
      method:  'PATCH',
      headers: {
        'Content-Type': 'application/merge-patch+json',
        'If-Match':     etag,
      },
      body: JSON.stringify(patched),
    })

    if (patchRes.ok) return patchRes.json()

    if (patchRes.status === 412) {
      // Concurrent modification — wait and retry
      await new Promise(r => setTimeout(r, 100 * 2 ** attempt)) // 100ms, 200ms, 400ms
      continue
    }

    throw new Error(`Update failed: ${patchRes.status}`)
  }
  throw new Error('Max retries exceeded — concurrent modification persists')
}

The key insight of ETag optimistic locking is that the version check is pushed into the database's WHERE clause, not performed in application code. This eliminates the time-of-check/time-of-use (TOCTOU) race: two concurrent requests with the same old ETag will both attempt WHERE version = 5, but only one will match and update — the other sees 0 rows affected and returns 412. This works correctly without any application-level locking or serialized transactions.

PostgreSQL JSONB Versioned Updates

PostgreSQL JSONB columns paired with a version integer implement optimistic concurrency at the database level. The jsonb_set() function modifies individual fields without rewriting the entire document; the version = $expected predicate in WHERE ensures atomicity. This pattern scales to thousands of concurrent requests without connection-level locks.

-- ── Schema with versioning ──────────────────────────────────────
CREATE TABLE documents (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  data       JSONB NOT NULL DEFAULT '{}',
  version    INTEGER NOT NULL DEFAULT 1,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- GIN index for JSON containment queries (@>, ?)
CREATE INDEX idx_documents_data_gin ON documents USING GIN (data);

-- ── Optimistic update: single field ──────────────────────────────
-- Returns the updated row if version matched, 0 rows if concurrent write
UPDATE documents
  SET data       = jsonb_set(data, '{status}', '"active"'),
      version    = version + 1,
      updated_at = NOW()
WHERE id = $1
  AND version = $2      -- optimistic lock: only apply if version matches
RETURNING id, data, version;

-- ── Optimistic update: multiple fields atomically ─────────────────
-- Use nested jsonb_set calls for multiple fields
UPDATE documents
  SET data = jsonb_set(
               jsonb_set(data, '{status}',   '"published"'),
                              '{publishedAt}', to_jsonb(NOW()::TEXT)
             ),
      version    = version + 1,
      updated_at = NOW()
WHERE id = $1 AND version = $2
RETURNING id, data, version;

-- ── Apply JSON Merge Patch in SQL ─────────────────────────────────
-- Merge patch via jsonb concatenation (|| operator):
-- Present keys override, but null keys must be explicitly deleted
UPDATE documents
  SET data = (
    (data || $2::jsonb)                           -- merge present fields
    - ARRAY(                                       -- remove null-valued keys
        SELECT key
        FROM jsonb_each($2::jsonb)
        WHERE value = 'null'::jsonb
      )
  ),
      version    = version + 1,
      updated_at = NOW()
WHERE id = $1 AND version = $3
RETURNING id, data, version;

-- ── Node.js: typed helper for versioned JSONB updates ─────────────import { Pool } from 'pg'

const pool = new Pool()

interface UpdateResult<T> {
  success: boolean
  data?: T
  version?: number
  conflict?: true
}

async function updateJsonbField<T>(
  id: string,
  path: string[],         // e.g. ['status'] or ['meta', 'score']
  value: unknown,
  expectedVersion: number,
): Promise<UpdateResult<T>> {
  const pathLiteral = '{' + path.join(',') + '}'
  const { rows, rowCount } = await pool.query(
    `UPDATE documents
       SET data       = jsonb_set(data, $1, $2::jsonb),
           version    = version + 1,
           updated_at = NOW()
     WHERE id = $3 AND version = $4
     RETURNING data, version`,
    [pathLiteral, JSON.stringify(value), id, expectedVersion],
  )

  if (rowCount === 0) return { success: false, conflict: true }
  return { success: true, data: rows[0].data as T, version: rows[0].version }
}

// Usage:
const result = await updateJsonbField(
  'doc-uuid',
  ['status'],
  'active',
  5, // expected version
)

if (result.conflict) {
  // Re-fetch and retry
}

For array operations — appending to a JSONB array without overwriting it — use jsonb_insert: {"jsonb_insert(data, '{tags,-1}', '"newtag"'::jsonb, true)"} appends after the last element. To remove an array element by index: {"data #- '{tags,2}'"} removes the element at index 2. To remove by value rather than index, use a subquery with jsonb_agg and jsonb_array_elements. These PostgreSQL-native operations avoid round-tripping the entire JSONB document through application code for large documents.

Last Write Wins vs Conflict Detection: Tradeoffs

Last write wins (LWW) is the default behavior when no concurrency control is implemented: the final PATCH or PUT overwrites whatever was there. It is simple to implement and produces no 412 errors, but it silently discards concurrent changes. Understanding when LWW is acceptable versus when you must detect conflicts is the first design decision in any concurrent update system.

// ── Decision matrix ───────────────────────────────────────────────
//
// Scenario                          Strategy
// ─────────────────────────────────────────────────────────────────
// Single-user editing (no concurrency)  LWW — no overhead needed
// Config file, rarely changed           LWW — conflicts are rare and tolerable
// Order/payment processing              Optimistic locking (version+ETag)
// Collaborative document editing        CRDTs (Automerge / Yjs)
// Inventory decrement (counter)         Database atomic: UPDATE SET qty = qty - 1
// User profile (multiple sessions)      Optimistic locking (field-level conflict UI)
// Analytics event ingestion             LWW or append-only (no updates)
// IoT sensor telemetry                  Timestamp-based LWW (latest reading wins)

// ── LWW is fine: append-only events ──────────────────────────────
// When writes never update the same field from different clients,
// LWW cannot lose data. Event logs, audit trails, and append-only
// feeds have no concurrent update problem.
async function appendEvent(streamId: string, event: object) {
  await db.query(
    'INSERT INTO events (stream_id, data, ts) VALUES ($1, $2, NOW())',
    [streamId, event],
  )
  // No version check needed — we're only adding, never overwriting
}

// ── LWW loses data: concurrent field updates ──────────────────────
// Client A reads:  { status: 'draft',     priority: 3 }
// Client B reads:  { status: 'draft',     priority: 3 }
// Client A writes: { status: 'published', priority: 3 }  ← sets status
// Client B writes: { status: 'draft',     priority: 5 }  ← overwrites status back!
// Final:           { status: 'draft',     priority: 5 }  ← Client A's work is lost

// ── Atomic counter updates — no LWW or ETag needed ───────────────
// For numeric counters, use database atomic increment instead of read-modify-write
async function decrementInventory(productId: string, quantity: number) {
  const { rows } = await db.query(
    `UPDATE products
       SET stock = stock - $1
     WHERE id = $2 AND stock >= $1   -- prevent negative stock
     RETURNING stock`,
    [quantity, productId],
  )
  if (rows.length === 0) throw new Error('Insufficient stock or product not found')
  return rows[0].stock
}

// ── Conflict detection with field-level granularity ───────────────
// When two clients modify different fields, it's not a conflict.
// Only overlapping edits need conflict UI.
function detectConflict(
  base: Record<string, unknown>,
  ours: Record<string, unknown>,
  theirs: Record<string, unknown>,
): { conflicts: string[]; safe: Record<string, unknown> } {
  const conflicts: string[] = []
  const merged: Record<string, unknown> = { ...base }

  const ourKeys   = new Set(Object.keys(ours))
  const theirKeys = new Set(Object.keys(theirs))

  for (const key of new Set([...ourKeys, ...theirKeys])) {
    const ourChanged   = ourKeys.has(key)   && JSON.stringify(ours[key])   !== JSON.stringify(base[key])
    const theirChanged = theirKeys.has(key) && JSON.stringify(theirs[key]) !== JSON.stringify(base[key])

    if (ourChanged && theirChanged && JSON.stringify(ours[key]) !== JSON.stringify(theirs[key])) {
      conflicts.push(key)     // both changed this key differently — real conflict
    } else if (ourChanged) {
      merged[key] = ours[key]
    } else if (theirChanged) {
      merged[key] = theirs[key]
    }
  }

  return { conflicts, safe: merged }
}

// If conflicts.length === 0, apply merged automatically.
// If conflicts.length > 0, show a conflict UI for those fields only.

The single most common mistake with concurrent JSON updates is using a full PUT without any version control — every update is a potential LWW overwrite. Even adding a simple integer version column and checking it in the WHERE clause is enough to detect conflicts. The performance overhead is a single integer comparison in the database — negligible compared to the cost of lost data.

CRDTs: Conflict-Free Collaborative JSON Editing

CRDTs (Conflict-free Replicated Data Types) eliminate the concept of conflict entirely: two clients can make any changes independently and the CRDT algorithm guarantees they will converge to the same state when synced, regardless of the order updates arrive. This makes CRDTs the right choice for real-time collaborative editing, offline-first apps, and any scenario where requiring the user to resolve 412 conflicts is unacceptable.

// ── Automerge: CRDT for JSON documents ───────────────────────────
import * as Automerge from '@automerge/automerge'

// Create a new shared document
let doc = Automerge.init<{ status: string; tags: string[]; score: number }>()
doc = Automerge.change(doc, d => {
  d.status = 'draft'
  d.tags   = ['backend']
  d.score  = 0
})

// ── Two clients edit independently (offline) ─────────────────────
// Client A: change status
let docA = Automerge.clone(doc)
docA = Automerge.change(docA, d => {
  d.status = 'published'  // Client A changes status
})

// Client B: add a tag and increment score (no network access during edit)
let docB = Automerge.clone(doc)
docB = Automerge.change(docB, d => {
  d.tags.push('api')      // Client B adds a tag
  d.score = d.score + 10  // Client B increments score
})

// ── Merge: both changes are preserved automatically ───────────────
const merged = Automerge.merge(docA, docB)
// merged.status === 'published'  (from Client A)
// merged.tags   === ['backend', 'api']  (from Client B — append preserved!)
// merged.score  === 10  (from Client B)
// No conflict! Non-overlapping edits are always merged correctly.

// ── Sync via binary encoding ──────────────────────────────────────
// Automerge documents serialize to compact binary format (not plain JSON)
const syncState = Automerge.initSyncState()
const [, message] = Automerge.generateSyncMessage(docA, syncState)
// Send message (Uint8Array) to server or peer over WebSocket

// Receive and apply sync message:
const [updatedDoc] = Automerge.receiveSyncMessage(docB, syncState, message!)

// ── Yjs: CRDT optimized for text and rich documents ──────────────
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

const ydoc = new Y.Doc()

// Y.Map mirrors a JSON object with concurrent-safe set/delete
const ymap = ydoc.getMap<string | number>('document')
ymap.set('status', 'draft')
ymap.set('score', 0)

// Y.Array mirrors a JSON array with concurrent-safe insert/delete
const ytags = ydoc.getArray<string>('tags')
ytags.push(['backend'])

// Connect to a Yjs WebSocket sync server (y-websocket)
const provider = new WebsocketProvider('wss://api.example.com/yjs', 'room-id', ydoc)

// Observe changes from remote clients
ymap.observe(event => {
  event.changes.keys.forEach((change, key) => {
    if (change.action === 'add' || change.action === 'update') {
      console.log(`${key} changed to ${ymap.get(key)}`)
    } else if (change.action === 'delete') {
      console.log(`${key} deleted`)
    }
  })
})

// Serialize Y.Map to plain JSON for API responses or database storage
function ymapToJson(map: Y.Map<unknown>): Record<string, unknown> {
  return Object.fromEntries(map.entries())
}

// ── CRDT backend: persist to PostgreSQL ──────────────────────────
// Store the CRDT binary state alongside the plain JSON for queries
async function saveAutomergeDoc(id: string, doc: Automerge.Doc<unknown>) {
  const binary = Automerge.save(doc)         // Uint8Array — full document state
  const json   = JSON.stringify(doc)         // plain JSON for queries/display
  await db.query(
    'INSERT INTO crdt_docs (id, crdt_state, json_view) VALUES ($1, $2, $3) ON CONFLICT (id) DO UPDATE SET crdt_state = $2, json_view = $3',
    [id, Buffer.from(binary), json],
  )
}

async function loadAutomergeDoc(id: string) {
  const { rows } = await db.query('SELECT crdt_state FROM crdt_docs WHERE id = $1', [id])
  if (!rows[0]) return Automerge.init()
  return Automerge.load(new Uint8Array(rows[0].crdt_state))
}

CRDTs have two costs compared to optimistic locking: storage overhead (the CRDT document format is larger than plain JSON, often 2–5×) and operation size (each edit records metadata for convergence). For most REST APIs handling form submissions or config updates, optimistic locking is simpler and sufficient. Reach for CRDTs when you need Google Docs-style simultaneous editing, offline-first mobile apps, or peer-to-peer sync without a central authority.

Implementing Optimistic Locking in a REST API

A complete optimistic locking implementation covers four concerns: version generation, GET response headers, PUT/PATCH request validation, and client retry logic. The following example shows a production-ready Next.js App Router implementation with Zod validation.

// ── app/api/documents/[id]/route.ts ──────────────────────────────
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

// Zod schema for the document
const DocumentSchema = z.object({
  title:  z.string().min(1).max(200),
  status: z.enum(['draft', 'published', 'archived']),
  body:   z.string(),
  tags:   z.array(z.string()).max(10).optional(),
})

type Document = z.infer<typeof DocumentSchema>

// ── GET /api/documents/[id] ───────────────────────────────────────
export async function GET(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  const row = await db.query(
    'SELECT data, version FROM documents WHERE id = $1',
    [params.id],
  )
  if (!row.rows[0]) return NextResponse.json({ error: 'Not Found' }, { status: 404 })

  const { data, version } = row.rows[0]
  const etag = `"${version}"`

  // Conditional GET: 304 if ETag matches
  if (req.headers.get('if-none-match') === etag) {
    return new NextResponse(null, { status: 304, headers: { ETag: etag } })
  }

  return NextResponse.json(
    { ...data, version },  // Include version in body for non-header clients
    {
      headers: {
        ETag:            etag,
        'Cache-Control': 'no-cache, must-revalidate',
        'Last-Modified': new Date().toUTCString(),
      },
    },
  )
}

// ── PATCH /api/documents/[id] — Merge Patch ───────────────────────
export async function PATCH(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  // Require If-Match
  const ifMatch = req.headers.get('if-match')
  if (!ifMatch) {
    return NextResponse.json(
      {
        type:   'https://jsonic.io/errors/precondition-required',
        title:  'Precondition Required',
        detail: 'Include If-Match header with the document ETag to prevent concurrent writes',
        status: 428,
      },
      { status: 428 },
    )
  }

  const expectedVersion = parseInt(ifMatch.replace(/"/g, ''), 10)
  if (isNaN(expectedVersion)) {
    return NextResponse.json({ error: 'Invalid If-Match value' }, { status: 400 })
  }

  // Parse and validate body as a partial document
  let patch: Partial<Document>
  try {
    const body = await req.json()
    patch = DocumentSchema.partial().parse(body)
  } catch {
    return NextResponse.json({ error: 'Invalid patch body' }, { status: 400 })
  }

  // Apply merge patch atomically in the database
  // Build jsonb_set chain dynamically or use || merge operator
  const result = await db.query(
    `UPDATE documents
       SET data       = data || $1::jsonb,
           version    = version + 1,
           updated_at = NOW()
     WHERE id = $2 AND version = $3
     RETURNING data, version`,
    [JSON.stringify(patch), params.id, expectedVersion],
  )

  if (result.rowCount === 0) {
    // Check if document exists at all
    const exists = await db.query('SELECT 1 FROM documents WHERE id = $1', [params.id])
    if (!exists.rowCount) {
      return NextResponse.json({ error: 'Not Found' }, { status: 404 })
    }
    return NextResponse.json(
      {
        type:   'https://jsonic.io/errors/concurrent-modification',
        title:  'Precondition Failed',
        detail: 'The document was modified by another client. Re-fetch and retry.',
        status: 412,
      },
      { status: 412 },
    )
  }

  const { data, version } = result.rows[0]
  return NextResponse.json(
    { ...data, version },
    { headers: { ETag: `"${version}"` } },
  )
}

// ── Client hook with retry ────────────────────────────────────────
import { useState, useCallback } from 'react'

function useOptimisticPatch(baseUrl: string) {
  const [error, setError] = useState<string | null>(null)
  const [loading, setLoading] = useState(false)

  const patch = useCallback(async (
    id: string,
    changes: Record<string, unknown>,
    maxRetries = 3,
  ) => {
    setLoading(true)
    setError(null)

    for (let attempt = 0; attempt < maxRetries; attempt++) {
      // Fetch current state to get ETag
      const getRes = await fetch(`${baseUrl}/${id}`)
      const etag   = getRes.headers.get('etag') ?? ''
      await getRes.json()  // consume body

      const patchRes = await fetch(`${baseUrl}/${id}`, {
        method:  'PATCH',
        headers: {
          'Content-Type': 'application/merge-patch+json',
          'If-Match':     etag,
        },
        body: JSON.stringify(changes),
      })

      if (patchRes.ok) {
        setLoading(false)
        return patchRes.json()
      }

      if (patchRes.status === 412 && attempt < maxRetries - 1) {
        // Exponential backoff before retry
        await new Promise(r => setTimeout(r, 100 * 2 ** attempt))
        continue
      }

      const errorBody = await patchRes.json()
      setError(errorBody.detail ?? 'Update failed')
      setLoading(false)
      throw new Error(errorBody.detail)
    }
  }, [baseUrl])

  return { patch, error, loading }
}

Return the version field inside the JSON response body in addition to the ETag header — this allows clients that cannot access response headers (GraphQL resolvers, some mobile HTTP clients) to track the version. Use RFC 7807 Problem Details format for 412 and 428 error responses, including a type URI that documents what the error means and how to recover. The detail field should explicitly tell the client to re-fetch and retry.

Key Terms

JSON Patch (RFC 6902)
A standard for expressing a sequence of operations to apply to a JSON document. Operations are add, remove, replace, move, copy, and test, each targeting a path specified as an RFC 6901 JSON Pointer. All operations in a Patch array are applied atomically — either all succeed or none are applied. Transmitted as application/json-patch+json in HTTP PATCH requests. The test operation provides built-in optimistic concurrency: if the test fails, the server returns 409 Conflict and no changes are made.
JSON Merge Patch (RFC 7396)
A simpler partial-update format where the patch body is a partial JSON object: present keys override the resource's values, and keys set to null are deleted from the target. Unlike JSON Patch, Merge Patch cannot manipulate individual array elements, express moves, or set a field to null (since null means delete). Transmitted as application/merge-patch+json. Ideal for REST APIs updating flat or shallowly nested resources where the ability to delete keys by setting them to null is more useful than array element manipulation.
optimistic locking
A concurrency control strategy that assumes conflicts are rare: clients read a resource and record its version, make changes locally, then submit the update with the version they read. The server rejects the update if the resource has changed since then (returning 412), rather than blocking the read with a pessimistic lock. Optimistic locking adds no server-side lock state and supports unlimited concurrent readers. The version token is conveyed via the HTTP ETag response header and If-Match request header, or embedded as a version field in the JSON body. The version check must be performed atomically inside the database query to prevent TOCTOU races.
ETag
An HTTP response header (RFC 7232) containing an opaque identifier for a specific version of a resource. The server generates the ETag value — typically a hash of the response body or a monotonic version integer, always quoted: ETag: "v42". Clients send the ETag back in subsequent requests: If-Match for conditional modifications (optimistic locking), If-None-Match for conditional GETs (cache revalidation returning 304 Not Modified). Strong ETags (no W/ prefix) require byte-for-byte identical representations; weak ETags (W/"v42") require only semantic equivalence and may be used for cache revalidation but not for If-Match conditional updates.
412 Precondition Failed
An HTTP status code returned when a conditional request's precondition (the If-Match or If-Unmodified-Since header) evaluates to false. For optimistic locking, 412 means the ETag the client sent no longer matches the resource's current ETag — a concurrent writer modified it first. The client must not assume any changes were applied; it should re-fetch the resource and retry the operation. Do not confuse with 409 Conflict (which signals an application-level semantic conflict) or 428 Precondition Required (which signals a missing required precondition header).
CRDT
Conflict-free Replicated Data Type — a mathematical data structure designed so that concurrent modifications from any number of replicas always converge to the same state when merged, without requiring coordination or conflict resolution by the user. CRDTs guarantee convergence by construction: every operation is commutative, associative, and idempotent. For JSON documents, operation-based CRDTs (such as Automerge) record each change as an operation and merge operation logs; state-based CRDTs (such as some Yjs types) exchange full state snapshots. CRDTs are the foundation of collaborative editing tools (Figma, Linear, Notion) and offline-first mobile applications.

FAQ

What is JSON Patch and how does it differ from a full PUT request?

JSON Patch (RFC 6902) sends a JSON array of operation objects — each specifying an op ("add", "remove", "replace", "move", "copy", "test"), a path (RFC 6901 JSON Pointer), and optionally a value — that the server applies atomically to a target document. A full PUT request replaces the entire resource with the provided body, which causes two problems under concurrency: any fields the client did not know about are silently deleted, and two clients who both read version N and both PUT version N+1 will have one overwrite the other's changes completely. JSON Patch solves both problems. Because a Patch document describes only the delta, it is safe to apply even if the resource has grown new fields the client never saw. And because each operation is precise, a "replace /status active" patch does not conflict with a concurrent "replace /priority high" patch touching a different path. The server applies all 6 operation types atomically: either every operation succeeds or none do. RFC 6902 Patch arrays commonly contain 1 to 10 operations; a PUT body for the same resource might be 50–500 fields.

What is the difference between JSON Patch and JSON Merge Patch?

JSON Patch (RFC 6902) represents changes as an array of operation objects with explicit op/path/value keys, supporting 6 operation types. JSON Merge Patch (RFC 7396) uses a simpler model: send a partial JSON object where present keys override the resource's keys and keys set to null are deleted. For example, {"status":"active","deleted":null} sets status and removes the deleted key in one PATCH request. Merge Patch is ideal for simple field updates on flat or shallowly nested resources — it requires no library. JSON Patch is necessary when you need array element manipulation by index, move/copy operations, or the test operation for conditional patching. JSON Merge Patch cannot express "delete array element at index 2" without rewriting the entire array, while JSON Patch expresses it as {"op":"remove","path":"/items/2"}. Choose Merge Patch for simple REST PATCH endpoints; choose JSON Patch when clients need fine-grained control over nested structures or need the test operation for optimistic concurrency.

How does ETag-based optimistic locking prevent lost updates?

ETag optimistic locking works in three steps. First, when a client GETs a resource, the server returns an ETag header containing a version token — typically a hash or a monotonic version number, for example ETag: "v5". Second, the client includes this token in subsequent PATCH or PUT requests as If-Match: "v5". Third, the server compares the If-Match value against the current ETag before applying changes. If they match, the update proceeds and the server returns a new ETag. If they do not match — because another client modified the resource in the meantime — the server returns 412 Precondition Failed without making any change. The client must re-fetch the resource, merge its intended changes onto the new state, and retry. This prevents lost updates: Client A reads version 5, Client B reads version 5, Client B writes version 6, Client A tries to write — the server rejects Client A's write with 412, forcing a re-read. ETags add no server-side lock state; the server remains stateless between requests.

What HTTP status code does the server return on a concurrency conflict?

412 Precondition Failed is the correct status code when an If-Match conditional request fails — meaning the ETag the client sent no longer matches the resource's current ETag, indicating a concurrent modification. The server must not apply any changes and must return 412. A 409 Conflict is appropriate for application-level conflicts — for example, when a JSON Patch test operation inside the patch body fails, or when business rules prevent the update regardless of version state. 428 Precondition Required signals that the server requires clients to send If-Match but the client omitted it. The difference matters for clients: 412 means "re-fetch and retry", 409 may require user intervention, 428 means "add the If-Match header". Never use 200 or 204 for a failed update — silent success on a conflict causes data loss.

How do I implement optimistic locking in a REST API?

Implementing optimistic locking requires four steps. First, add a version integer to your resource, incremented on every write, and return it as both an ETag response header and a version field in the JSON body. Second, in GET handlers, set the ETag and Cache-Control: no-cache headers. Third, in PUT/PATCH handlers, read the If-Match header — return 428 if absent and your API requires it. Fourth, perform the version check atomically inside the database: UPDATE docs SET ... WHERE id=$1 AND version=$2 RETURNING *. If 0 rows are updated, the version changed — return 412. Never read the version, check it in application code, then write separately: this creates a TOCTOU race. In PostgreSQL, use the RETURNING clause to confirm the update succeeded. Client libraries should automatically retry on 412 with exponential backoff — typically 3 retries with 100ms, 200ms, 400ms delays.

What are CRDTs and when should I use them for JSON?

CRDTs (Conflict-free Replicated Data Types) are data structures designed so that concurrent edits from multiple clients merge automatically without conflicts. Unlike optimistic locking — which rejects conflicting writes and requires retry — CRDTs converge to a correct merged state mathematically. For JSON documents, Automerge and Yjs are the two primary libraries. Automerge records every local change as an operation; when two clients sync, it merges their operation logs and resolves conflicts deterministically. Yjs uses shared types (Y.Map, Y.Array, Y.Text) and syncs efficiently using state vectors. Use CRDTs when you need real-time collaborative editing (multiple users editing simultaneously), offline-first applications (clients edit while disconnected and sync later), or when the UX cost of "412 — please retry" is unacceptable. Avoid CRDTs for simple REST CRUD APIs where optimistic locking suffices — CRDTs add complexity: CRDT document storage is 2–5× larger than raw JSON, and the sync protocol requires infrastructure (WebSocket server, state vector management).

How do I use JSON Patch with a PostgreSQL JSONB column?

For small patches, apply the patch in application code using fast-json-patch, then write the result: UPDATE docs SET data = $1, version = version + 1 WHERE id = $2 AND version = $3. For large documents where fetching the entire JSONB column is expensive, map JSON Patch operations directly to PostgreSQL functions: a "replace" on /status becomes {"jsonb_set(data, '{status}', '"active"'::jsonb)"}; a "remove" on /items/2 becomes {"data #- '{items,2}'"}; an "add" to array end becomes {"jsonb_insert(data, '{tags,-1}', '"newtag"'::jsonb, true)"}. Always wrap in the version check: WHERE id=$1 AND version=$2. If 0 rows are updated, return 412. Combine multiple operations using nested jsonb_set calls or sequential CTEs. For complex patches with many operations, the application-code approach is more maintainable.

How do I handle a 412 Precondition Failed response in the client?

Handling 412 correctly requires three steps: re-fetch, merge, and retry. First, when you receive 412, discard your cached copy — it is stale. Re-fetch the resource with GET to obtain the latest version and its new ETag. Second, merge your intended changes onto the freshly fetched state. For non-overlapping edits (you changed /status, the other client changed /priority), apply both changes trivially. For overlapping edits, perform a three-way merge comparing your original base, the new server state, and your local changes — surface real conflicts to the user. Third, retry the PATCH with the new ETag. Use exponential backoff: 100ms, 200ms, 400ms delays before surfacing an error after a maximum of 3–5 retries. In React, use TanStack Query's onError callback to trigger the re-fetch/merge/retry cycle. Never silently swallow a 412 — it represents a real data conflict that, if ignored, causes data loss for one of the concurrent writers.

Further reading and primary sources