Conflict Resolution Algorithms

Building resilient offline-first applications requires deterministic state reconciliation. When network partitions occur, local mutations and remote updates inevitably diverge, and the moment connectivity returns you must merge two histories of the same record without losing data or corrupting state. This guide details production-grade conflict resolution for browser storage, focusing on IndexedDB transaction boundaries, logical clock synchronization, and deterministic merge pipelines tailored for modern PWAs and mobile web. It is part of the broader Offline Sync Strategies & Background Workflows approach, and it pairs directly with how cached responses are served through Service Worker Caching Strategies and how queued mutations are flushed via Background Sync API Implementation.

Offline conflict resolution decision flow A decision tree showing how a diverged local and remote record is routed to last-write-wins, vector clock merge, or CRDT merge based on concurrency and data type. Local + remote diverged record Causally ordered? compare vector clocks Concurrent edit? same field, both changed Apply newer causal winner wins Last-write-wins logical tiebreaker CRDT merge commutative, no loss

The reconciliation problem

A single record edited on two devices while both are offline produces two valid but divergent versions. There is no globally correct answer the browser can compute for you; the algorithm you choose encodes a policy. The three families below trade simplicity for correctness, and the diagram above shows how a diverged record is routed between them. The choice between Last-Write-Wins vs CRDT for Offline Notes is the canonical worked example of this trade-off.

Strategy Detects concurrency Data loss risk Storage overhead Best for
Last-Write-Wins (LWW) No High (silent overwrite) Minimal (one timestamp) Single-field, low-contention records
Vector clocks Yes (causality) Medium (manual merge) Linear in client count Multi-device CRUD with audit needs
CRDTs Yes (by construction) None (commutative merge) High (operation log) Collaborative text and counters

Last-Write-Wins is the cheapest: stamp every write and keep the latest. Its flaw is that “latest” is meaningless across devices whose clocks disagree, and it silently discards the losing edit. Vector clocks fix detection — they tell you whether two versions are causally ordered or genuinely concurrent — but leave the merge of concurrent edits to you. Conflict-free Replicated Data Types (CRDTs) go further by defining a merge function that is commutative, associative, and idempotent, so any two replicas that have seen the same set of operations converge to the same state regardless of order. The vector-clock mechanics are unpacked in Implementing Vector Clocks for Offline Sync.

1. Environment setup & state baseline

Deterministic conflict resolution begins with a rigorously versioned storage layer. IndexedDB remains the only viable client-side store for complex offline state because of its transactional guarantees and asynchronous API. Default browser quotas (up to roughly 1 GB per origin on Safari, up to about 60% of free disk on Chromium) and auto-commit transaction behavior force explicit schema design from the start.

Initialize your database with a versioned schema that embeds logical ordering primitives. Every record must carry a vectorClock (or an equivalent logical timestamp), a stable clientId derived from crypto.randomUUID() or a persisted device identifier, and a lastModified sequence counter. Tag every local mutation with these fields before connectivity is restored, so the merge pipeline has the metadata it needs.

interface SyncRecord<T> {
  id: string;
  payload: T;
  clientId: string;            // stable per device, persisted once
  vectorClock: Record<string, number>; // clientId -> counter
  lastModified: number;        // monotonic logical sequence
}

function openSyncDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open('sync-store', 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      const store = db.createObjectStore('records', { keyPath: 'id' });
      store.createIndex('byModified', 'lastModified');
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

Capture a pre-sync snapshot at the point requests are intercepted. By caching immutable state hashes alongside API responses through Service Worker Caching Strategies, you gain a verifiable checkpoint to roll back to when a merge is rejected server-side.

Checklist:

2. Algorithm implementation & sync pipeline

Deploy a deterministic merge pipeline that prioritizes logical ordering over wall-clock time. For standard CRUD records, vector clock comparison or Last-Write-Wins with a logical tiebreaker gives predictable outcomes. In high-concurrency collaborative environments — shared documents, live counters, ordered lists — transition to a CRDT approach to guarantee eventual consistency without a central arbiter. The two best-known JavaScript CRDT libraries, Yjs and Automerge, implement these merge functions for you; both expose a binary update format you can persist as a blob in IndexedDB and replay on load. The trade-offs between an LWW field and a full CRDT document are explored in depth in Last-Write-Wins vs CRDT for Offline Notes.

Queue resolved payloads and trigger background reconciliation through the SyncManager API. Background Sync API Implementation guarantees delivery during intermittent connectivity but requires careful payload batching to respect background execution budgets. Dispatch resolved deltas optimistically, but keep a strict boundary between the presentation layer and the reconciliation engine so a rejected merge never leaves the UI showing phantom state — the rollback mechanics live in Optimistic UI Updates & Rollback.

// Vector clock comparison: returns 'local', 'remote', or 'concurrent'.
type Clock = Record<string, number>;

function compareClocks(a: Clock, b: Clock): 'local' | 'remote' | 'concurrent' {
  const ids = new Set([...Object.keys(a), ...Object.keys(b)]);
  let aDominates = false;
  let bDominates = false;
  for (const id of ids) {
    const av = a[id] ?? 0;
    const bv = b[id] ?? 0;
    if (av > bv) aDominates = true;
    if (bv > av) bDominates = true;
  }
  if (aDominates && !bDominates) return 'local';
  if (bDominates && !aDominates) return 'remote';
  return 'concurrent'; // genuine conflict — needs a merge policy
}

async function resolveConflict<T>(
  local: SyncRecord<T>,
  remote: SyncRecord<T>,
  mergeConcurrent: (l: SyncRecord<T>, r: SyncRecord<T>) => SyncRecord<T>,
): Promise<SyncRecord<T>> {
  const ordering = compareClocks(local.vectorClock, remote.vectorClock);
  if (ordering === 'local') return local;
  if (ordering === 'remote') return remote;
  // Concurrent: apply the caller's policy (LWW tiebreaker, CRDT merge, etc.)
  return mergeConcurrent(local, remote);
}

The mergeConcurrent callback is where policy lives. A Last-Write-Wins policy compares lastModified and falls back to a deterministic clientId ordering when the counters tie, so every replica resolves identically:

function lwwMerge<T>(l: SyncRecord<T>, r: SyncRecord<T>): SyncRecord<T> {
  if (l.lastModified !== r.lastModified) {
    return l.lastModified > r.lastModified ? l : r;
  }
  // Deterministic tiebreak so all clients agree on the same winner.
  return l.clientId > r.clientId ? l : r;
}

Checklist:

3. Edge cases & network instability

Race conditions, partial syncs, and system clock skew are inevitable in distributed offline environments. Relying on Date.now() introduces ordering anomalies across devices whose NTP clients disagree by seconds or minutes. Replace wall-clock dependencies with Lamport timestamps or hybrid logical clocks to enforce strict causal ordering, and treat the wall clock only as a coarse tiebreaker of last resort.

When network flakiness interrupts the sync pipeline, wrap outbound requests in robust retry logic as described in Handling Network Flakiness with Exponential Backoff. Attach an idempotency key to every outbound mutation so reconnect storms cannot produce duplicate writes; the server deduplicates on that key without any client-side state corruption.

// Reject merges built on skewed clocks before they corrupt the store.
function normalizeClock(record: SyncRecord<unknown>, localSeq: number): SyncRecord<unknown> {
  const counter = record.vectorClock[record.clientId] ?? 0;
  return {
    ...record,
    lastModified: Math.max(record.lastModified, localSeq + 1),
    vectorClock: { ...record.vectorClock, [record.clientId]: counter + 1 },
  };
}

Checklist:

4. Browser compatibility

The primitives this pipeline depends on are widely available, but a few iOS Safari caveats shape production behavior.

Capability Chrome Firefox Safari Edge
IndexedDB transactions Yes Yes Yes (iOS 16/17 auto-commit is aggressive) Yes
crypto.randomUUID() 92+ 95+ 15.4+ 92+
Background Sync (SyncManager) Yes No No Yes
BroadcastChannel Yes Yes 15.4+ Yes
navigator.storage.persist() Yes Yes Partial (ITP still evicts) Yes

Because SyncManager is unavailable in Safari and Firefox, treat Background Sync as an enhancement and keep a foreground fallback that flushes the queue on the next online event or app launch. On iOS Safari 16 and 17 the transaction auto-commits the moment the event loop yields, so never await a non-IndexedDB promise mid-transaction or the transaction closes underneath you and the merge write is silently dropped.

5. Debugging & production monitoring

Conflict resolution pipelines need structured telemetry to surface divergence rates, merge failures, and fallback frequency. Instrument the state manager with trace IDs that span a mutation’s full lifecycle: local commit, background sync, server acknowledgment.

interface ConflictEvent<T> {
  local: SyncRecord<T>;
  remote: SyncRecord<T>;
  strategy: 'lww' | 'vector' | 'crdt';
  fallbackUsed: boolean;
}

function attachConflictDebugger<T>(
  on: (event: 'conflict', cb: (p: ConflictEvent<T>) => void) => void,
  telemetry: { track: (name: string, data: unknown) => void },
): void {
  on('conflict', (payload) => {
    const traceId = crypto.randomUUID(); // requires a secure context (HTTPS/localhost)
    telemetry.track('sync_conflict', {
      traceId,
      strategy: payload.strategy,
      fallbackUsed: payload.fallbackUsed,
      localClock: payload.local.vectorClock,
      remoteClock: payload.remote.vectorClock,
    });
  });
}

Log resolution strategy and clock deltas to your observability platform with strict PII filtering, and add rollback hooks for server-side validation failures. Set an alert when fallback invocation exceeds about 5% of total sync operations — a rising fallback rate usually means clock skew or a schema mismatch, not random network noise.

Frequently Asked Questions

When is Last-Write-Wins good enough?

Last-Write-Wins is fine when conflicts are rare, records are single-purpose (a setting, a status flag), and silently dropping the losing edit is acceptable. The moment two users can meaningfully edit the same field offline — notes, shared lists, collaborative documents — move to vector clocks for detection or a CRDT for automatic merging. See Last-Write-Wins vs CRDT for Offline Notes.

Why not just use Date.now() to order writes?

Device clocks drift, and a phone that is a minute fast will always “win” against a correct one, silently discarding newer edits. Logical clocks such as vector clocks or Lamport timestamps order events by causality rather than wall time, so the result is deterministic regardless of how badly the clocks disagree. The implementation is covered in Implementing Vector Clocks for Offline Sync.

Do I need a CRDT library, or can I write the merge myself?

For LWW and vector-clock CRUD you can write the merge yourself in a few dozen lines. For collaborative text or ordered lists, the merge function is genuinely hard to get right, and libraries such as Yjs and Automerge implement battle-tested CRDT types you can persist as a binary blob in IndexedDB. Reach for them only when you actually have concurrent, character-level editing.

How do I stop a reconnect storm from creating duplicate records?

Attach a stable idempotency key to every mutation before it is queued, and have the server deduplicate on that key. Combine this with the retry policy in Handling Network Flakiness with Exponential Backoff so retries reuse the same key rather than minting a new write each attempt.

Related