This page narrows the general problem covered in Offline-First Data Sync & Conflict Resolution down to one concrete decision: when two offline clients edit the same record and both reconnect, which edit does the server keep?
The exact failure this page solves #
A note-taking app lets two tabs — a phone and a laptop — go offline simultaneously. On the phone, a user edits the title field of a shared record. On the laptop, a different user edits the body field of the same record, seconds later but with no network path between the two devices. Both queue their write in a background sync replay queue and flush when connectivity returns. The server receives two PUT requests for the same id within milliseconds of each other, in arbitrary order. If it naively keeps whichever request arrives last, the title edit or the body edit disappears without any error, log line, or user-visible warning — the classic silent data-loss failure of a wall-clock-only merge strategy.
The fix is not a bigger try/catch. It is choosing a conflict-resolution strategy that can tell the difference between “these edits happened one after another” and “these edits happened concurrently, and neither should be discarded outright.” This page implements both strategies in one resolve() function, shows a decision rule for picking between them, and covers the metadata trade-offs each strategy imposes on a record.
Zero-to-working implementation #
The snippet below implements Last-Write-Wins (LWW) and version-vector comparison as two branches of the same resolve() entry point. A production sync layer typically ships version vectors as the default and keeps LWW only as the deterministic tie-break for genuinely concurrent writes.
// resolve.ts
type VersionVector = Record<string, number>; // replicaId -> local counter
interface SyncRecord<T> {
id: string;
data: T;
vector: VersionVector;
updatedAt: number; // wall-clock ms, used only for LWW and tie-breaks
replicaId: string; // origin of the last write, used as final tiebreaker
deleted?: boolean; // tombstone flag
}
type Relation = 'before' | 'after' | 'equal' | 'concurrent';
// Compares two vectors by causal dominance (a classic vector-clock compare).
function compareVectors(a: VersionVector, b: VersionVector): Relation {
let aHigher = false;
let bHigher = false;
const replicas = new Set([...Object.keys(a), ...Object.keys(b)]);
for (const id of replicas) {
const av = a[id] ?? 0;
const bv = b[id] ?? 0;
if (av > bv) aHigher = true;
if (bv > av) bHigher = true;
}
if (aHigher && bHigher) return 'concurrent';
if (aHigher) return 'after';
if (bHigher) return 'before';
return 'equal';
}
// Last-Write-Wins: always returns a single winner, may discard real edits.
function resolveLWW<T>(local: SyncRecord<T>, remote: SyncRecord<T>): SyncRecord<T> {
if (local.updatedAt !== remote.updatedAt) {
return local.updatedAt > remote.updatedAt ? local : remote;
}
return local.replicaId > remote.replicaId ? local : remote; // tie-break
}
// Version-vector aware resolve: fast path on dominance, merge on true concurrency.
function resolve<T extends Record<string, unknown>>(
local: SyncRecord<T>,
remote: SyncRecord<T>,
): SyncRecord<T> {
const relation = compareVectors(local.vector, remote.vector);
if (relation === 'after') return local;
if (relation === 'before') return remote;
if (relation === 'equal') return local;
// Concurrent: tombstones dominate unless resurrected by a strictly later vector.
if (local.deleted || remote.deleted) {
return local.deleted ? local : remote;
}
// Field-level merge, falling back to LWW only per conflicting key.
const merged = { ...remote.data, ...local.data } as T;
const winner = resolveLWW(local, remote);
return { ...winner, data: merged };
}
Step-by-step explanation #
VersionVectorandSyncRecord— the metadata each replica carries. Every write increments the writing replica’s own counter invector;updatedAtandreplicaIdexist only to break ties when vectors cannot decide.compareVectors()— causal comparison, not recency. It walks the union of replica IDs and tracks whether either side has a strictly higher counter anywhere; both being true means neither history contains the other, which is the formal definition of concurrent edits.- Fast path on
'after'/'before'. If one record’s vector dominates, the other record’s edit is already causally contained in it — keeping the dominant record loses nothing, because no independent edit happened on the other side. 'equal'short-circuit. Identical vectors mean the same write was seen twice (a retried sync request); either record is correct, solocalis returned without further work.- Tombstone precedence on
'concurrent'. Adeletedflag from either side wins over a concurrent data edit, matching user expectation that deleting a record should not be silently reverted by an edit that raced it. - Field-level merge as the last resort. When both sides are live and genuinely concurrent,
resolve()spreadsremote.datathenlocal.dataso non-overlapping fields merge automatically, and callsresolveLWW()only to decide which record’s metadata (and any truly overlapping field) wins.
Comparison table #
| Dimension | Last-Write-Wins | Version vectors |
|---|---|---|
| Conflict detection | None — always picks a winner | Detects true concurrency vs. causal order |
| Data safety | Silently drops the losing edit | Preserves non-overlapping fields via merge |
| Metadata overhead | One timestamp per record | One counter per active replica |
| Clock dependency | Sensitive to clock skew | Immune — counters are logical, not wall-clock |
Decision rule #
Use version vectors as the default comparator for any record two or more offline clients can edit independently, and reserve LWW purely as the tie-break inside resolve() for fields that genuinely overlap after a concurrent write is detected. Skip version vectors entirely only for single-writer records (per-user preferences, a record no shared replica ever touches) — the vector overhead buys nothing when concurrent writers cannot exist. This mirrors the storage-tier trade-off already made for client persistence in LocalStorage & IndexedDB Sync Strategies: pick the cheaper mechanism until the failure mode it can’t handle actually shows up in your data.
Edge cases #
| Scenario | Symptom | Mitigation |
|---|---|---|
| Clock skew between devices | LWW tie-break picks the wrong “later” edit because a device’s clock is fast or slow | Prefer a logical (Lamport) counter over Date.now() for the tie-break field, or clamp updatedAt to server-received time before comparing |
| Tombstones vs. concurrent edits | A delete and an edit race; naive merge resurrects a record the user just deleted | Tombstones dominate on 'concurrent' unless the edit’s vector strictly postdates the delete’s vector (an explicit resurrection) |
| Unbounded vector growth | vector accumulates one entry per replica ID ever seen, bloating every record over the app’s lifetime |
Prune entries for replica IDs inactive beyond a retention window (for example 90 days) during a periodic compaction pass, not during hot-path sync |
| Tie-breaking on identical timestamps | Two devices commit within the same millisecond; updatedAt alone can’t order them |
Fall back to comparing replicaId lexically as a stable, arbitrary, but deterministic final tiebreaker |
Verification steps #
Confirm the resolution logic with a unit test that constructs two genuinely concurrent edits and asserts the merge outcome, not just the final winner:
// resolve.test.ts
import { describe, it, expect } from 'vitest';
describe('resolve() under true concurrency', () => {
it('merges non-overlapping fields from both concurrent edits', () => {
const base: VersionVector = { phone: 1, laptop: 1 };
const local = {
id: 'note-1',
data: { title: 'Grocery list' }, // phone edited title
vector: { ...base, phone: 2 },
updatedAt: 1_000,
replicaId: 'phone',
};
const remote = {
id: 'note-1',
data: { title: 'Untitled', body: 'Milk, eggs' }, // laptop edited body
vector: { ...base, laptop: 2 },
updatedAt: 1_050,
replicaId: 'laptop',
};
const merged = resolve(local, remote);
expect(merged.data.title).toBe('Grocery list'); // local field preserved
expect(merged.data.body).toBe('Milk, eggs'); // remote field preserved
});
});
Run npx vitest run resolve.test.ts and confirm it passes. Then verify the dominance fast path separately: construct a remote whose vector is local’s vector with one counter incremented, assert resolve() returns remote directly, and confirm local’s discarded fields never appear in application logs as a “conflict” — a true causal successor is not a conflict and should not be reported as one.
Frequently asked questions #
Can I mix both strategies on the same record type?
Yes, and the resolve() function above already does — it uses version vectors for the cheap fast-path decision (dominance, equality) and only invokes LWW-style tie-breaking on the genuinely concurrent branch. Treating LWW as a fallback rather than the primary strategy keeps its data-loss risk confined to the small subset of edits that actually overlap.
What happens if a client never sends its vector, only a timestamp?
compareVectors() cannot classify the relationship correctly if one side is missing entries, so treat any record without a vector as vector {} — it will compare as strictly dominated by any record with a non-empty vector, and as concurrent otherwise. Practically, this means legacy clients should be upgraded to emit at least their own replica counter before they’re allowed to write through the same resolve() path.
Related #
- Offline-First Data Sync & Conflict Resolution — parent guide covering the broader sync architecture this conflict-resolution step fits into
- Background Sync & Request Replay Queues — how queued offline writes reach the server in the first place, upstream of the conflict this page resolves
- LocalStorage & IndexedDB Sync Strategies — client-side storage trade-offs that determine where local vectors and pending writes live before sync