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. Without a deliberate resolution policy the failure mode is silent: a user edits a note on their phone, edits it again on their laptop, and one of those edits vanishes on the next sync with no error and no trace. 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: a diverged local and remote record is first tested for causal ordering. If one vector clock dominates, the newer version wins. If the versions are concurrent, the data type decides — a scalar field takes last-write-wins with a logical tiebreaker, while a collaborative structure such as text, a list, or a counter takes a CRDT merge. Local + remote diverged record Causally ordered? compare vector clocks Collaborative type? text, list, or counter Apply newer causal winner wins Last-write-wins logical tiebreaker CRDT merge commutative, no loss ordered concurrent scalar collab

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.

Resolution API surface

Whichever strategy you deploy, the reconciliation engine exposes a small, stable contract: a comparison function that classifies two records, and a resolver that returns the survivor. Nailing these signatures down first keeps the merge policy swappable without touching the storage layer.

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
}

type Ordering = 'local' | 'remote' | 'concurrent';

// Classify two records by causality.
function compareClocks(a: Clock, b: Clock): Ordering;

// Resolve a diverged pair, delegating true conflicts to a policy callback.
function resolveConflict<T>(
  local: SyncRecord<T>,
  remote: SyncRecord<T>,
  mergeConcurrent: (l: SyncRecord<T>, r: SyncRecord<T>) => SyncRecord<T>,
): Promise<SyncRecord<T>>;
Parameter Type Role
local SyncRecord<T> The version currently in IndexedDB on this device.
remote SyncRecord<T> The version returned by the server or a peer.
mergeConcurrent (l, r) => SyncRecord<T> Policy invoked only when the two versions are genuinely concurrent.

The return contract is deliberately narrow. compareClocks returns exactly one of 'local', 'remote', or 'concurrent'; the first two are dominance results (one clock strictly happened-after the other), the third means neither dominates and a merge policy must decide. resolveConflict resolves to a single SyncRecord<T> — never a partial or a null — so callers always have a concrete record to persist and render.

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.

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. Concurrency & lifecycle considerations

Conflict resolution runs inside the same event loop and the same IndexedDB transactions as the rest of your app, so the merge is only correct if its lifecycle is watertight. Two failure modes dominate.

Transaction auto-commit. An IndexedDB transaction stays alive only while it has pending requests; the instant the event loop yields with none outstanding, the transaction commits and closes. If your resolver awaits a fetch, a crypto.subtle call, or any non-IndexedDB promise between reading the local record and writing the merged result, the read transaction closes underneath you and the write throws TransactionInactiveError. Resolve the merge in pure memory first, then open a fresh readwrite transaction that does nothing but read-verify-write in one synchronous burst.

Cross-tab convergence. Two tabs of the same origin share one IndexedDB but run independent JavaScript. If both reconcile the same record after a reconnect, they can race and one clobbers the other. Broadcast the resolved record over a BroadcastChannel so every tab applies the same winner and no tab re-resolves stale state.

const channel = new BroadcastChannel('sync-reconcile');

async function commitResolved<T>(db: IDBDatabase, merged: SyncRecord<T>): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction('records', 'readwrite');
    tx.objectStore('records').put(merged); // no awaits between open and put
    tx.oncomplete = () => resolve();
    tx.onerror = () => reject(tx.error);
  });
  channel.postMessage({ type: 'resolved', record: merged });
}

channel.onmessage = (e) => {
  if (e.data?.type === 'resolved') applyToUi(e.data.record); // no re-resolve
};

4. Error handling & retry strategy

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:

5. 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. On the same versions, navigator.storage.persist() can report success yet still be overridden by Intelligent Tracking Prevention after seven days of no interaction, so never assume a locally resolved record is durable — always re-sync it to the server.

6. Performance & scale considerations

Reconciliation cost is dominated by three things: how many records you compare per sync, how long each merge holds a transaction lock, and how much operation history a CRDT accumulates.

Pressure point Symptom at scale Mitigation
Full-store scans on every sync Reconnect stalls for hundreds of ms as record count grows Index on lastModified and reconcile only records newer than the last acknowledged sequence
Long merge transactions Writes from other tabs block; TransactionInactiveError under load Resolve in memory, then write in one short readwrite transaction; batch puts in a single transaction
Unbounded CRDT op-log Blob size and load-time replay grow without limit Periodically snapshot to a compacted document and prune tombstoned operations
Vector clock width Clock object grows linearly with lifetime device count Prune entries for devices retired beyond a retention window

Batch reconciliation in chunks of a few hundred records rather than one transaction per record — transaction setup dominates at small sizes — but stay well under the SyncManager background execution budget so a large backlog does not get the whole sync event killed mid-flight. For CRDT documents, replay time on load is proportional to op-log length, so snapshot aggressively; a compacted Yjs or Automerge document loads in constant time regardless of edit history.

Conflict resolution pipelines also need structured telemetry to surface divergence rates, merge failures, and fallback frequency. Instrument the resolver with trace IDs that span a mutation’s full lifecycle — local commit, background sync, server acknowledgment — and 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.

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

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

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