JSON Real-Time Sync: CRDTs, Operational Transform, and Conflict Resolution

Last updated:

JSON real-time sync enables multiple clients to edit shared state simultaneously — CRDTs (Conflict-free Replicated Data Types) and Operational Transform are the two dominant approaches to merging concurrent JSON edits without conflicts. CRDTs guarantee eventual consistency without a central coordinator — Yjs uses a CRDT-based shared document (Y.Map, Y.Array) that serializes to a compact binary update, not plain JSON. JSON Patch (RFC 6902) with Operational Transform applies operations sequentially with transform functions that adjust indices when concurrent inserts collide. This guide covers Yjs shared document structure, JSON Patch operational transform, last-write-wins (LWW) strategy, Firebase Realtime Database JSON sync, optimistic updates, and conflict detection with vector clocks. Whether you're building a collaborative editor, a multiplayer whiteboard, or a real-time form with concurrent users, understanding these primitives lets you choose the right sync model for your data shape and conflict tolerance. Use Jsonic's JSON formatter to inspect sync payloads and document snapshots while developing.

CRDTs vs Operational Transform for JSON

The fundamental problem in real-time JSON sync is convergence: if two clients make concurrent edits to the same document and those edits are broadcast to each other, both clients must end up with the same resulting state. Two algorithmic families solve this — CRDTs and Operational Transform — with different tradeoffs around architecture, complexity, and document type.

CRDTs are data structures designed so that any two replicas converge to the same state after receiving the same set of operations, regardless of delivery order. A CRDT for JSON must handle the three fundamental conflict scenarios: concurrent set of the same key, concurrent array insertion at the same index, and concurrent delete-and-edit of the same element. Yjs solves all three using a doubly-linked list of unique operation IDs internally — each character or item in a CRDT document has a globally unique ID that defines its position even after concurrent insertions. CRDTs require no central server for conflict resolution and support peer-to-peer sync natively.

Operational Transformtakes a different approach: operations (insert, delete, replace) are transformed before application so that a concurrent operation from another client is adjusted to remain correct relative to already-applied local operations. Classic OT (as used in Google Docs' early implementation) requires a central server to impose a canonical operation ordering — without a server, the transform function must handle all possible operation orderings, which becomes combinatorially complex for rich document types. JSON Patch OT is practical for structured documents with low concurrency; CRDTs are preferable for high-concurrency collaborative text editing.

Choose CRDTs (Yjs, Automerge) when you need peer-to-peer sync, offline-first editing, or low-latency conflict-free merges. Choose OT (ShareDB, JSON Patch + server) when your document structure maps cleanly to JSON Patch operations and you have a central server to sequence operations. For simpler use cases — syncing settings, presence, or non-overlapping structured fields — last-write-wins is sufficient and far easier to implement. See the JSON WebSocket guide for the transport layer that carries sync messages.

Yjs Shared JSON Documents

Yjs is the most widely adopted CRDT library for JavaScript. It provides shared types — Y.Map, Y.Array, and Y.Text — that behave like normal JavaScript objects but automatically merge concurrent edits. A Yjs document is the root container; shared types are nested inside it. The WebSocket provider (y-websocket) handles broadcasting binary updates between peers and a persistence provider (y-indexeddb) handles offline storage.

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

// Create a shared Yjs document
const doc = new Y.Doc()

// Connect to a y-websocket server for real-time sync
const provider = new WebsocketProvider(
  'wss://sync.example.com',
  'my-room-id',  // room name — all clients with the same room sync together
  doc
)

// Define shared data structures rooted at the document
const root = doc.getMap('root')           // Y.Map — like a JSON object
const items = doc.getArray('items')       // Y.Array — like a JSON array
const title = doc.getText('title')        // Y.Text — CRDT string for collaborative text

// Reading current state as plain JSON
console.log(root.toJSON())    // { key: value, ... }
console.log(items.toJSON())   // [item1, item2, ...]

// Writing — mutations inside doc.transact() are batched into a single update
doc.transact(() => {
  root.set('status', 'active')
  root.set('updatedAt', new Date().toISOString())
  items.push([{ id: crypto.randomUUID(), text: 'New item' }])
})

// Observing changes — fires whenever any peer (including local) mutates the type
root.observe((event) => {
  console.log('Root changed:', event.changes.keys)
  // event.changes.keys is a Map<string, { action: 'add'|'update'|'delete', oldValue }>
})

items.observe((event) => {
  console.log('Items changed, delta:', event.changes.delta)
  // delta is an array of { insert: [...] } | { delete: number } | { retain: number }
})

// Awareness — share ephemeral state like cursor position, online presence
provider.awareness.setLocalStateField('user', {
  name: 'Alice',
  color: '#ff6b6b',
  cursor: { path: '/items/2', offset: 5 },
})

provider.awareness.on('change', ({ added, updated, removed }) => {
  // All peers' awareness states
  const states = Array.from(provider.awareness.getStates().entries())
  console.log('Online users:', states.map(([, state]) => state.user?.name))
})

Yjs binary updates are Uint8Array values, not JSON strings. When you need to persist the document state (e.g., in a database), encode the update with Y.encodeStateAsUpdate(doc) and store the bytes. Restore it with Y.applyUpdate(doc, storedUpdate). For REST API consumption of the current document state, call .toJSON() on any shared type to get a plain JavaScript object. Never send the raw binary update over a REST endpoint — it is a delta, not a snapshot; apply all historical updates to reconstruct the full document. See the JSON event-driven guide for patterns around distributing Yjs updates through message queues.

JSON Patch Operational Transform

JSON Patch (RFC 6902) defines six atomic operations on a JSON document: add, remove, replace, move, copy, and test. Each operation targets a JSON Pointer path (RFC 6901). When two clients produce concurrent patches, applying them in different orders produces divergent results — Operational Transform resolves this by adjusting the second patch relative to the first before application.

// Two clients produce concurrent patches against the same document base
// Base document: { "items": ["a", "b", "c"] }

// Client A: insert "x" at index 0
const patchA = [{ op: 'add', path: '/items/0', value: 'x' }]
// Result if applied alone: ["x", "a", "b", "c"]

// Client B: insert "y" at index 0 (concurrent with A)
const patchB = [{ op: 'add', path: '/items/0', value: 'y' }]
// Result if applied alone: ["y", "a", "b", "c"]

// If we apply patchA then patchB without OT:
// After A: ["x", "a", "b", "c"]
// After B (still targeting index 0): ["y", "x", "a", "b", "c"]  ← both at 0

// With OT: transform patchB relative to patchA
function transformAddOp(
  op: { op: string; path: string; value: unknown },
  appliedOp: { op: string; path: string; value: unknown }
): { op: string; path: string; value: unknown } {
  // Parse array index from JSON Pointer path like "/items/0"
  const parseIndex = (path: string) => {
    const parts = path.split('/')
    const idx = parseInt(parts[parts.length - 1], 10)
    return { prefix: parts.slice(0, -1).join('/'), idx }
  }

  const incoming = parseIndex(op.path)
  const applied = parseIndex(appliedOp.path)

  // Same parent array, same or earlier index — shift incoming index up by 1
  if (
    appliedOp.op === 'add' &&
    incoming.prefix === applied.prefix &&
    !isNaN(applied.idx) &&
    !isNaN(incoming.idx) &&
    applied.idx <= incoming.idx
  ) {
    return {
      ...op,
      path: `${incoming.prefix}/${incoming.idx + 1}`,
    }
  }
  return op
}

const transformedPatchB = patchB.map(op => transformAddOp(op, patchA[0]))
// transformedPatchB: [{ op: 'add', path: '/items/1', value: 'y' }]

// Now apply patchA then transformedPatchB:
// After A: ["x", "a", "b", "c"]
// After transformed B: ["x", "y", "a", "b", "c"]  ← correct: both inserts preserved
import { applyPatch } from 'fast-json-patch'

// Server-side OT coordinator: sequences all patches and broadcasts transformed versions
const operationLog: Array<{ clientId: string; patch: object[]; seq: number }> = []
let document = { items: ['a', 'b', 'c'] }

function applyWithOT(incomingPatch: object[], clientId: string, clientSeq: number) {
  // Find all operations applied since clientSeq (the client's known base)
  const concurrent = operationLog.filter(op => op.seq > clientSeq)

  // Transform incoming patch against all concurrent operations
  let transformed = incomingPatch
  for (const concurrentOp of concurrent) {
    transformed = transformed.map(op =>
      transformAddOp(
        op as { op: string; path: string; value: unknown },
        concurrentOp.patch[0] as { op: string; path: string; value: unknown }
      )
    )
  }

  // Apply to server document
  const result = applyPatch(document, transformed as any, true, false)
  document = result.newDocument

  // Log with new sequence number
  const seq = (operationLog[operationLog.length - 1]?.seq ?? 0) + 1
  operationLog.push({ clientId, patch: transformed, seq })

  return { transformedPatch: transformed, seq }
}

Real-world JSON Patch OT must handle all six operation types, including the complex interactions between concurrent remove and add on the same path, and concurrent move operations. Libraries like ShareDB implement full OT for JSON; building a production-grade transform function from scratch is non-trivial. For structured JSON documents where concurrent edits to the same array index are rare, a simpler approach is to use field-level LWW (last-write-wins) with JSON Patch for non-conflicting fields. See the JSON diff and patch guide for the underlying RFC 6902 operations.

Last-Write-Wins JSON Sync

Last-write-wins (LWW) is the simplest conflict resolution strategy: when two clients write to the same field concurrently, the write with the higher timestamp wins. LWW is appropriate for fields where only the latest value matters — user presence, cursor position, settings toggles, status fields. It is inappropriate for collaborative text or ordered lists where every concurrent edit should be preserved.

// LWW Register — wraps a value with metadata for conflict resolution
interface LWWRegister<T> {
  value: T
  timestamp: number   // Unix milliseconds
  clientId: string    // Unique client ID (UUID) for tie-breaking
}

// Create an LWW register
function lwwRegister<T>(value: T, clientId: string): LWWRegister<T> {
  return { value, timestamp: Date.now(), clientId }
}

// Merge two LWW registers — returns the winner
function mergeLWW<T>(local: LWWRegister<T>, remote: LWWRegister<T>): LWWRegister<T> {
  if (remote.timestamp > local.timestamp) return remote
  if (remote.timestamp < local.timestamp) return local
  // Tie: higher clientId wins (lexicographic, deterministic)
  return remote.clientId > local.clientId ? remote : local
}

// Example: sync a user settings JSON document
interface UserSettings {
  theme: LWWRegister<'light' | 'dark'>
  notifications: LWWRegister<boolean>
  language: LWWRegister<string>
}

const myClientId = crypto.randomUUID()

// Local state
let localSettings: UserSettings = {
  theme: lwwRegister('dark', myClientId),
  notifications: lwwRegister(true, myClientId),
  language: lwwRegister('en', myClientId),
}

// Incoming remote update (from another client or server)
function applyRemoteSettings(remote: Partial<UserSettings>) {
  for (const key of Object.keys(remote) as (keyof UserSettings)[]) {
    const remoteField = remote[key]
    if (remoteField) {
      // Type-safe merge per field
      (localSettings[key] as LWWRegister<unknown>) = mergeLWW(
        localSettings[key] as LWWRegister<unknown>,
        remoteField as LWWRegister<unknown>
      )
    }
  }
}

// Read current values (strip LWW metadata for UI)
function readSettings(settings: UserSettings) {
  return {
    theme: settings.theme.value,
    notifications: settings.notifications.value,
    language: settings.language.value,
  }
}
// Vector clock for causal ordering (beyond wall-clock LWW)
type VectorClock = Record<string, number>

function incrementClock(clock: VectorClock, clientId: string): VectorClock {
  return { ...clock, [clientId]: (clock[clientId] ?? 0) + 1 }
}

// Compare vector clocks to detect concurrent writes
type ClockRelation = 'before' | 'after' | 'concurrent' | 'equal'

function compareClocks(a: VectorClock, b: VectorClock): ClockRelation {
  const allKeys = new Set([...Object.keys(a), ...Object.keys(b)])
  let aLessOrEqual = true
  let bLessOrEqual = true

  for (const k of allKeys) {
    const av = a[k] ?? 0
    const bv = b[k] ?? 0
    if (av > bv) bLessOrEqual = false
    if (bv > av) aLessOrEqual = false
  }

  if (aLessOrEqual && bLessOrEqual) return 'equal'
  if (aLessOrEqual) return 'before'
  if (bLessOrEqual) return 'after'
  return 'concurrent'  // ← conflict detected
}

// JSON document version with vector clock
interface VersionedDoc<T> {
  data: T
  clock: VectorClock
  clientId: string
}

function mergeVersioned<T>(
  local: VersionedDoc<T>,
  remote: VersionedDoc<T>,
  merge: (a: T, b: T) => T
): VersionedDoc<T> {
  const relation = compareClocks(local.clock, remote.clock)
  if (relation === 'before') return remote
  if (relation === 'after') return local
  if (relation === 'equal') return local
  // Concurrent: merge data and take max of each clock entry
  const allKeys = new Set([...Object.keys(local.clock), ...Object.keys(remote.clock)])
  const mergedClock: VectorClock = {}
  for (const k of allKeys) {
    mergedClock[k] = Math.max(local.clock[k] ?? 0, remote.clock[k] ?? 0)
  }
  return { data: merge(local.data, remote.data), clock: mergedClock, clientId: local.clientId }
}

LWW with a vector clock is more accurate than LWW with wall-clock timestamps because it detects causality: if client B's edit was made after seeing client A's edit (its clock entry for A is at least as large as A's own entry), there is no conflict. Clock skew between machines can cause wall-clock LWW to silently drop a newer logical edit. The vector clock overhead is a small JSON object per document version — acceptable for most sync scenarios. See the JSON data validation guide for validating the LWW register schema before applying remote updates.

Firebase Realtime Database JSON Sync

Firebase Realtime Database stores your entire application state as a single JSON tree and syncs changes to all connected clients in milliseconds via a persistent WebSocket connection. Firebase uses a server-authoritative model — the server is the single source of truth, and all clients are replicas. Conflict resolution is handled by transaction() for atomic read-modify-write operations.

import { initializeApp } from 'firebase/app'
import {
  getDatabase, ref, set, update, onValue,
  push, remove, transaction, serverTimestamp,
} from 'firebase/database'

const app = initializeApp({ databaseURL: 'https://my-app.firebaseio.com' })
const db = getDatabase(app)

// --- set() — overwrites the entire node ---
// Use when replacing a complete JSON subtree
await set(ref(db, 'rooms/room-42'), {
  name: 'Design Team',
  createdAt: serverTimestamp(),
  members: { 'user-1': true, 'user-2': true },
})

// --- update() — shallow merge: only listed keys are changed ---
// Does NOT recurse into nested objects — only top-level keys of the target ref
await update(ref(db, 'rooms/room-42'), {
  name: 'Design Team (Updated)',
  updatedAt: serverTimestamp(),
  // members is NOT touched — only name and updatedAt are changed
})

// --- push() — append to a list with Firebase-generated key ---
const newItemRef = push(ref(db, 'rooms/room-42/messages'))
await set(newItemRef, {
  text: 'Hello',
  userId: 'user-1',
  timestamp: serverTimestamp(),
})
// newItemRef.key is something like "-NxKtBj1..." — a time-ordered unique key

// --- onValue — real-time listener: fires on connect and every change ---
const unsubscribe = onValue(ref(db, 'rooms/room-42'), (snapshot) => {
  if (snapshot.exists()) {
    const room = snapshot.val()  // plain JSON object
    console.log('Room data:', room)
    renderRoom(room)
  } else {
    console.log('Room does not exist')
  }
}, (error) => {
  console.error('Permission denied or other error:', error)
})

// --- transaction() — atomic read-modify-write for concurrent edits ---
// Safe for counters, toggle fields, and inventory-style decrement
const likesRef = ref(db, 'posts/post-1/likes')

const result = await transaction(likesRef, (currentLikes) => {
  // currentLikes is null if the node doesn't exist yet
  if (currentLikes === null) return 1
  return currentLikes + 1
  // Return undefined to abort the transaction without changing the value
})

if (result.committed) {
  console.log('New likes count:', result.snapshot.val())
} else {
  console.log('Transaction aborted')
}

// Detach listener when component unmounts
unsubscribe()
// Firebase Security Rules (JSON format in Firebase Console)
// Rules control read/write access per path
{
  "rules": {
    "rooms": {
      "$roomId": {
        // Only authenticated users can read
        ".read": "auth !== null",
        // Only room members can write
        ".write": "auth !== null && data.child('members/' + auth.uid).exists()",
        "messages": {
          "$messageId": {
            // Users can only write messages with their own userId
            ".write": "newData.child('userId').val() === auth.uid"
          }
        }
      }
    }
  }
}

Firebase push() keys (like -NxKtBj1mT...) are chronologically ordered — sorting a list by key gives creation order without a separate timestamp field. Use serverTimestamp() instead of Date.now()on the client to avoid clock skew in ordering and LWW comparisons. For offline support, enable Firebase's local persistence: enableIndexedDbPersistence(db)— the database caches reads and buffers writes, replaying them when connectivity resumes. This makes Firebase a practical offline-first JSON sync solution without implementing your own queue. For high-write scenarios, Firestore (Firebase's document database) scales better than Realtime Database but uses a different API.

Optimistic Updates with JSON Rollback

Optimistic updates make real-time JSON apps feel instant by applying mutations to local state before the server confirms them. The local UI updates immediately; if the server rejects the operation (conflict, validation error, or permission denied), the state is rolled back to the pre-mutation snapshot. Done correctly, the user rarely sees the rollback — conflicts are infrequent and the round-trip is fast.

// React hook for optimistic JSON mutations
import { useState, useCallback } from 'react'

interface OptimisticState<T> {
  data: T
  pending: boolean
  error: string | null
}

function useOptimisticJSON<T>(initialData: T) {
  const [state, setState] = useState<OptimisticState<T>>({
    data: initialData,
    pending: false,
    error: null,
  })

  const mutate = useCallback(async (
    // optimisticUpdate: the change to apply immediately
    optimisticUpdate: (current: T) => T,
    // serverMutation: async function that sends the change to the server
    serverMutation: (optimisticData: T) => Promise<T>
  ) => {
    // Step 1: Save snapshot for rollback
    const snapshot = state.data

    // Step 2: Apply optimistic update immediately
    const optimisticData = optimisticUpdate(state.data)
    setState({ data: optimisticData, pending: true, error: null })

    try {
      // Step 3: Send to server and wait for confirmation
      const confirmedData = await serverMutation(optimisticData)

      // Step 4: Replace with server-confirmed data (may differ slightly)
      setState({ data: confirmedData, pending: false, error: null })
    } catch (err) {
      // Step 5: Rollback on server rejection
      setState({
        data: snapshot,
        pending: false,
        error: err instanceof Error ? err.message : 'Sync failed',
      })
    }
  }, [state.data])

  return { ...state, mutate }
}

// Usage in a collaborative todo list component
function TodoList() {
  const { data: todos, pending, error, mutate } = useOptimisticJSON<
    Array<{ id: string; text: string; done: boolean }>
  >([])

  const toggleTodo = (id: string) => {
    mutate(
      // Optimistic: flip the done field immediately
      (current) => current.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
      // Server: send PATCH and get confirmed state
      async (optimisticTodos) => {
        const res = await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' })
        if (!res.ok) throw new Error(`Server rejected toggle: ${res.status}`)
        return res.json()  // server returns updated todos array
      }
    )
  }

  return (
    <div>
      {pending && <span>Syncing...</span>}
      {error && <span style={{ color: 'red' }}>Conflict: {error} — changes reverted</span>}
      {todos.map(todo => (
        <div key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.done ? '✓' : '○'} {todo.text}
        </div>
      ))}
    </div>
  )
}

For optimistic updates over a WebSocket (rather than REST), the pattern is the same but the "server confirm" step listens for a correlated response message using a requestId. Apply the optimistic mutation to local state, send the mutation message with a requestId, and resolve or rollback when the server sends back a response with the matching requestId. Set a timeout (e.g., 5 seconds) to automatically rollback if no response arrives — this prevents the UI from getting stuck in a pending state during network interruptions. Store rollback snapshots only for in-flight operations, not indefinitely, to avoid unbounded memory growth.

Conflict Detection with Vector Clocks

Vector clocks provide causal conflict detection that wall-clock timestamps cannot. A vector clock is a JSON object mapping client IDs to logical counters: {"client-A": 3, "client-B": 1}. The counter encodes "how many operations from this client has this replica seen." When you compare two document versions by their vector clocks, you can determine whether one version causally precedes the other (no conflict) or whether they are concurrent (conflict detected).

// Vector clock JSON format
type VectorClock = Record<string, number>

// Every document version carries a vector clock
interface JSONDocVersion {
  id: string           // document ID
  data: unknown        // the JSON document content
  clock: VectorClock   // { [clientId]: logicalCounter }
  clientId: string     // which client produced this version
}

// Increment own counter before making a change
function makeEdit(
  current: JSONDocVersion,
  newData: unknown,
  clientId: string
): JSONDocVersion {
  return {
    ...current,
    data: newData,
    clock: { ...current.clock, [clientId]: (current.clock[clientId] ?? 0) + 1 },
    clientId,
  }
}

// Merge incoming clock (e.g., from a received sync message)
function mergeClock(local: VectorClock, remote: VectorClock): VectorClock {
  const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)])
  const merged: VectorClock = {}
  for (const k of allKeys) {
    merged[k] = Math.max(local[k] ?? 0, remote[k] ?? 0)
  }
  return merged
}

// Detect conflict between two document versions
function detectConflict(a: JSONDocVersion, b: JSONDocVersion): boolean {
  const relation = compareClocks(a.clock, b.clock)
  return relation === 'concurrent'
}

// Conflict resolution strategies
type ConflictStrategy = 'lww' | 'merge-fields' | 'user-prompt'

function resolveConflict(
  local: JSONDocVersion,
  remote: JSONDocVersion,
  strategy: ConflictStrategy
): JSONDocVersion {
  switch (strategy) {
    case 'lww': {
      // Fall back to wall-clock timestamp for tie-breaking
      const localTs = (local.data as any)._ts ?? 0
      const remoteTs = (remote.data as any)._ts ?? 0
      return remoteTs >= localTs ? remote : local
    }

    case 'merge-fields': {
      // Field-level LWW: keep the most recent value per field
      const localData = local.data as Record<string, unknown>
      const remoteData = remote.data as Record<string, unknown>
      const merged: Record<string, unknown> = { ...localData }

      for (const [key, remoteValue] of Object.entries(remoteData)) {
        const localCounter = local.clock[local.clientId] ?? 0
        const remoteCounter = remote.clock[remote.clientId] ?? 0
        if (remoteCounter > localCounter) {
          merged[key] = remoteValue
        }
      }

      return {
        ...local,
        data: merged,
        clock: mergeClock(local.clock, remote.clock),
      }
    }

    case 'user-prompt':
      // Return a special sentinel for the UI to handle
      throw new Error('CONFLICT_NEEDS_RESOLUTION')
  }
}

// Example: sync pipeline with conflict detection
async function syncDocument(
  local: JSONDocVersion,
  serverUrl: string
): Promise<JSONDocVersion> {
  const res = await fetch(`${serverUrl}/docs/${local.id}`)
  const remote: JSONDocVersion = await res.json()

  if (detectConflict(local, remote)) {
    console.warn('Conflict detected between local and remote versions', {
      localClock: local.clock,
      remoteClock: remote.clock,
    })
    return resolveConflict(local, remote, 'merge-fields')
  }

  // No conflict: take the causally newer version
  const relation = compareClocks(local.clock, remote.clock)
  return relation === 'after' ? local : remote
}

// Helper (same as in Section 4)
function compareClocks(a: VectorClock, b: VectorClock): 'before' | 'after' | 'concurrent' | 'equal' {
  const allKeys = new Set([...Object.keys(a), ...Object.keys(b)])
  let aLessOrEqual = true
  let bLessOrEqual = true
  for (const k of allKeys) {
    const av = a[k] ?? 0
    const bv = b[k] ?? 0
    if (av > bv) bLessOrEqual = false
    if (bv > av) aLessOrEqual = false
  }
  if (aLessOrEqual && bLessOrEqual) return 'equal'
  if (aLessOrEqual) return 'before'
  if (bLessOrEqual) return 'after'
  return 'concurrent'
}

Vector clocks grow as the number of distinct clients grows — in a system with thousands of ephemeral clients, the clock JSON object can become large. Bound clock size by pruning entries for clients not seen in the last N operations (version vector trimming) or by using a hybrid logical clock (HLC) that combines a physical timestamp with a logical counter in a single 64-bit value. For most applications with a bounded number of concurrent editors (a shared document with 2–50 simultaneous users), full vector clocks are practical with negligible overhead. Attach the vector clock to every WebSocket sync message and persist it alongside your JSON document in the database.

Definitions

CRDT (Conflict-free Replicated Data Type)
A data structure whose merge operation is commutative, associative, and idempotent — meaning any two replicas that receive the same set of operations converge to the same state, regardless of delivery order. Yjs and Automerge implement CRDTs for JSON-compatible data. CRDTs require no central coordinator for conflict resolution, enabling peer-to-peer and offline-first sync.
Operational Transform (OT)
An algorithm that adjusts concurrent operations before applying them so that applying op1 then transform(op2, op1) produces the same result as applying op2 then transform(op1, op2). Originally developed for real-time collaborative editing (Google Wave, Google Docs). For JSON documents, OT transforms RFC 6902 JSON Patch operations, adjusting array indices and paths to account for concurrent insertions and deletions.
Eventual consistency
A consistency model that guarantees all replicas will converge to the same state once all pending updates have propagated — without requiring synchronous agreement before each operation. Eventual consistency allows low-latency local writes and tolerates network partitions. CRDTs provide a specific, math-guaranteed form of eventual consistency called Strong Eventual Consistency (SEC).
Vector clock
A JSON object mapping client IDs to logical counters used to determine causal ordering between document versions. If all entries in clock A are less than or equal to the corresponding entries in clock B, then A happened-before B. If neither clock dominates the other, the two versions are concurrent — a conflict exists. Vector clocks detect causality violations that wall-clock timestamps miss due to clock skew.
Last-write-wins (LWW)
A conflict resolution strategy that keeps the write with the highest timestamp when two clients write to the same field concurrently. Each field is wrapped in an LWW register: {"value": …, "timestamp": …, "clientId": "…"}. LWW is simple and performant but permanently discards the losing write. Use it for fields where only the latest value matters (settings, presence, status).
Optimistic update
A UI pattern that applies a mutation to local state immediately — before the server confirms it — to make the interface feel instant. A snapshot of the pre-mutation state is saved. If the server confirms the operation, the snapshot is discarded. If the server rejects it (conflict or error), the local state is rolled back to the snapshot. Optimistic updates are most effective for low-conflict operations where server confirmation is expected to succeed the vast majority of the time.
Convergence
The property of a distributed system where all replicas eventually reach the same state after all updates have been applied. CRDTs achieve Strong Eventual Consistency — convergence is guaranteed by the data structure's mathematical properties, not by coordination. OT achieves convergence via deterministic transform functions. Both contrast with last-write-wins, which converges but may lose data, and with pessimistic locking, which prevents divergence by blocking concurrent writes.

Frequently asked questions

What is the difference between CRDTs and Operational Transform?

CRDTs are data structures where concurrent edits always merge deterministically without coordination — any two replicas converge after receiving the same operations, regardless of order. Operational Transform adjusts operations before applying them: a transform function shifts array indices when two concurrent inserts collide. CRDTs (Yjs, Automerge) need no central server for conflict resolution and support peer-to-peer sync. OT traditionally requires a server to impose canonical operation order. CRDTs are preferable for peer-to-peer and offline-first scenarios; OT is practical for server-coordinated collaborative editing with JSON Patch operations.

How does Yjs sync JSON data between clients?

Yjs maintains shared CRDT types — Y.Map, Y.Array, Y.Text — rooted in a Y.Doc. Each mutation generates a compact binary update (Uint8Array). The y-websocket provider broadcasts this binary update to all peers in the same room, and each peer applies it with Y.applyUpdate(doc, update). The document merges concurrent edits automatically using CRDT semantics. Read current state as plain JSON with doc.getMap('root').toJSON(). Yjs does not transmit plain JSON — it uses a proprietary binary format for efficiency.

What is JSON Patch Operational Transform?

JSON Patch (RFC 6902) defines atomic operations (add, remove, replace, etc.) targeting JSON Pointer paths. When two clients produce concurrent patches, applying them in different orders diverges. OT resolves this: a transform function adjusts the second patch relative to the first. For example, two concurrent add operations at /items/0 — the transform shifts one to /items/1 so both inserts are preserved. The server applies patches sequentially and broadcasts transformed versions to all clients.

How do I implement last-write-wins for JSON sync?

Wrap each field value in an LWW register: {"value": …, "timestamp": <unix_ms>, "clientId": "<uuid>"}. When merging two versions of the same field, keep the one with the higher timestamp. For equal timestamps, break the tie by comparing clientIdlexicographically (deterministic). To avoid clock skew issues, replace the wall-clock timestamp with a vector clock for causal ordering — this detects concurrent writes that wall-clock LWW would miss. LWW permanently loses the "losing" write — use it only for fields where the latest value is always correct.

How does Firebase Realtime Database sync JSON?

Firebase stores data as a JSON tree and syncs changes to all clients via a persistent WebSocket. Use set() to overwrite a node entirely, update() to merge only the specified top-level keys (shallow merge), and transaction() for atomic read-modify-write that retries automatically on conflict. Listen with onValue(ref, snapshot => {} snapshot.val()) — fires immediately and on every change. Firebase handles reconnection, offline caching, and queued writes automatically. Use serverTimestamp() to avoid clock skew in ordering and LWW comparisons.

What are optimistic updates in real-time JSON apps?

Optimistic updates apply a mutation to local JSON state immediately, before the server confirms it, so the UI feels instant. Save a snapshot of the pre-mutation state. On server success, discard the snapshot. On server rejection (conflict or error), roll back local state to the snapshot and notify the user. Over WebSocket, use a requestId to correlate the mutation message with the server response. Set a timeout (e.g., 5 seconds) to auto-rollback if no response arrives, preventing the UI from getting stuck in a pending state.

How do vector clocks detect JSON conflicts?

A vector clock is a JSON object mapping client IDs to logical counters: {"client-A": 3, "client-B": 1}. Compare two clocks: if all entries in clock A are <= the corresponding entries in clock B, then A happened-before B (no conflict). If A has some entries greater than B and B has some greater than A, the edits are concurrent — a conflict exists. Vector clocks detect causality violations that wall-clock timestamps miss due to clock skew, making them the correct tool for causal conflict detection in distributed JSON sync.

Can I use JSON Patch for collaborative editing?

Yes — JSON Patch (RFC 6902) works for collaborative editing of structured JSON when combined with OT to resolve concurrent edits. Each user action generates a minimal patch sent to a server that applies OT and broadcasts transformed patches to all clients. For text-heavy documents with many concurrent edits, Yjs or ShareDB is more robust. For structured forms where different users typically edit different fields, JSON Patch with field-level last-write-wins (no full OT required) is a practical and simpler alternative. See the JSON diff and patch guide for full RFC 6902 operation details.

Further reading and primary sources

Inspect JSON sync payloads visually

Paste any CRDT snapshot, JSON Patch array, vector clock, or Firebase JSON tree into Jsonic to pretty-print and navigate the structure. Instantly spot malformed patches, missing clock entries, and unexpected data shapes before they cause sync conflicts in production.

Open JSON Formatter