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.
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:
- Define
vectorClock,clientId, andlastModifiedfields with strict type validation. - Capture baseline state hashes before queuing mutations.
- Initialize a local state manager with immutable snapshot capabilities to prevent mid-transaction drift.
- Scope IndexedDB transactions explicitly (
readwritevsreadonly) to avoidTransactionInactiveErrorduring high-throughput writes.
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:
- Build an async reconciliation function with explicit handling for malformed payloads.
- Apply logical timestamp arbitration or CRDT merges based on data topology.
- Dispatch resolved deltas with optimistic UI updates backed by rollback hooks.
- Chunk payloads to stay within
SyncManagerbackground execution quotas.
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:
- Detect clock drift and normalize to logical sequence numbers before merge evaluation.
- Queue failed sync attempts with exponential backoff and randomized jitter to prevent thundering herds.
- Validate server acknowledgments against the local operation log before committing state transitions.
- Use an
AbortControllerto cancel stale sync requests when network topology changes.
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
- Last-Write-Wins vs CRDT for Offline Notes — the canonical trade-off between cheap LWW and lossless CRDT merges.
- Implementing Vector Clocks for Offline Sync — how to build and compare logical clocks for causal ordering.
- Handling Network Flakiness with Exponential Backoff — retry logic that feeds the reconciliation pipeline.
- Optimistic UI Updates & Rollback — applying merges to the UI and undoing rejected ones.
- Offline Sync Strategies & Background Workflows — the parent guide tying these workflows together.