Scope: Conflict resolution, CRDTs, operational transformation, and eventual consistency patterns Lines: 398 Last Updated: 2025-10-18
Scope: Conflict resolution, CRDTs, operational transformation, and eventual consistency patterns Lines: 398 Last Updated: 2025-10-18
Use real-time sync patterns when you need:
Don't use when:
1. Last-Write-Wins (LWW)
- Simplest approach
- Use timestamp or sequence number
- Data loss possible
2. Operational Transformation (OT)
- Transform operations based on concurrent ops
- Complex but preserves intent
- Used in Google Docs
3. Conflict-Free Replicated Data Types (CRDTs)
- Mathematical guarantees of convergence
- No conflict resolution needed
- Used in distributed systems
4. Three-Way Merge
- Compare: base, local, remote
- Identify conflicts explicitly
- User resolves ambiguities
Client A Server Client B
| | |
|-- Update A ---->| |
| |-- Broadcast -->|
| | |
|<-- Update B ----|<-- Update B ---|
| | |
|-- Resolve ----->| |
|<-- Merged ------|-- Merged ----->|
// Simple LWW with timestamps
interface VersionedData<T> {
data: T;
version: number;
timestamp: number;
clientId: string;
}
class LWWStore<T> {
private data: VersionedData<T> | null = null;
update(newData: T, clientId: string): VersionedData<T> {
const version = (this.data?.version ?? 0) + 1;
const timestamp = Date.now();
this.data = {
data: newData,
version,
timestamp,
clientId,
};
return this.data;
}
merge(remote: VersionedData<T>): boolean {
if (!this.data) {
this.data = remote;
return true;
}
// Resolve conflicts with LWW
if (remote.timestamp > this.data.timestamp) {
this.data = remote;
return true;
} else if (remote.timestamp === this.data.timestamp) {
// Tie-break with clientId for determinism
if (remote.clientId > this.data.clientId) {
this.data = remote;
return true;
}
}
return false; // No update
}
get(): T | null {
return this.data?.data ?? null;
}
}
// Usage
const store = new LWWStore<string>();
store.update('Hello', 'client-1');
// Simulate remote update
const remoteData = {
data: 'World',
version: 2,
timestamp: Date.now() + 1000,
clientId: 'client-2',
};
const updated = store.merge(remoteData);
console.log(updated); // true
console.log(store.get()); // 'World'
// LWW-Register CRDT
interface LWWRegister<T> {
value: T;
timestamp: number;
clientId: string;
}
class LWWRegisterCRDT<T> {
private register: LWWRegister<T>;
constructor(initialValue: T, clientId: string) {
this.register = {
value: initialValue,
timestamp: Date.now(),
clientId,
};
}
set(value: T, clientId: string): LWWRegister<T> {
this.register = {
value,
timestamp: Date.now(),
clientId,
};
return this.register;
}
merge(other: LWWRegister<T>): boolean {
if (other.timestamp > this.register.timestamp ||
(other.timestamp === this.register.timestamp &&
other.clientId > this.register.clientId)) {
this.register = other;
return true;
}
return false;
}
get(): T {
return this.register.value;
}
getState(): LWWRegister<T> {
return { ...this.register };
}
}
// G-Set: Elements can only be added, never removed
class GSet<T> {
private elements: Set<T> = new Set();
add(element: T): boolean {
if (this.elements.has(element)) {
return false;
}
this.elements.add(element);
return true;
}
has(element: T): boolean {
return this.elements.has(element);
}
merge(other: GSet<T>): void {
other.elements.forEach((element) => {
this.elements.add(element);
});
}
toArray(): T[] {
return Array.from(this.elements);
}
getState(): T[] {
return this.toArray();
}
}
// Usage
const set1 = new GSet<string>();
set1.add('a');
set1.add('b');
const set2 = new GSet<string>();
set2.add('b');
set2.add('c');
set1.merge(set2);
console.log(set1.toArray()); // ['a', 'b', 'c']
// 2P-Set: Elements can be added and removed (but only once each)
class TwoPhaseSet<T> {
private addSet: Set<T> = new Set();
private removeSet: Set<T> = new Set();
add(element: T): boolean {
if (this.removeSet.has(element)) {
return false; // Cannot re-add removed element
}
this.addSet.add(element);
return true;
}
remove(element: T): boolean {
if (!this.addSet.has(element)) {
return false; // Cannot remove non-existent element
}
this.removeSet.add(element);
return true;
}
has(element: T): boolean {
return this.addSet.has(element) && !this.removeSet.has(element);
}
merge(other: TwoPhaseSet<T>): void {
other.addSet.forEach((element) => this.addSet.add(element));
other.removeSet.forEach((element) => this.removeSet.add(element));
}
toArray(): T[] {
return Array.from(this.addSet).filter((element) => !this.removeSet.has(element));
}
getState() {
return {
added: Array.from(this.addSet),
removed: Array.from(this.removeSet),
};
}
}
// G-Counter: Grow-only counter (increment only)
class GCounter {
private counts: Map<string, number> = new Map();
increment(clientId: string, amount: number = 1): void {
const current = this.counts.get(clientId) ?? 0;
this.counts.set(clientId, current + amount);
}
value(): number {
let total = 0;
this.counts.forEach((count) => {
total += count;
});
return total;
}
merge(other: GCounter): void {
other.counts.forEach((count, clientId) => {
const current = this.counts.get(clientId) ?? 0;
this.counts.set(clientId, Math.max(current, count));
});
}
getState(): Map<string, number> {
return new Map(this.counts);
}
}
// PN-Counter: Positive-Negative counter (increment and decrement)
class PNCounter {
private increments: GCounter = new GCounter();
private decrements: GCounter = new GCounter();
increment(clientId: string, amount: number = 1): void {
this.increments.increment(clientId, amount);
}
decrement(clientId: string, amount: number = 1): void {
this.decrements.increment(clientId, amount);
}
value(): number {
return this.increments.value() - this.decrements.value();
}
merge(other: PNCounter): void {
this.increments.merge(other.increments);
this.decrements.merge(other.decrements);
}
}
// Usage
const counter = new PNCounter();
counter.increment('client-1', 5);
counter.decrement('client-1', 2);
console.log(counter.value()); // 3
// Simple OT for text insertion and deletion
interface Operation {
type: 'insert' | 'delete';
position: number;
content?: string;
length?: number;
}
class OperationalTransform {
static transform(op1: Operation, op2: Operation): Operation {
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position < op2.position) {
// op2 happens after op1's insertion
return {
...op2,
position: op2.position + (op1.content?.length ?? 0),
};
} else if (op1.position > op2.position) {
// op2 happens before op1, no transformation needed
return op2;
} else {
// Same position, use arbitrary tie-breaker
return {
...op2,
position: op2.position + (op1.content?.length ?? 0),
};
}
}
if (op1.type === 'delete' && op2.type === 'insert') {
if (op2.position <= op1.position) {
// op2 before deletion
return op2;
} else if (op2.position >= op1.position + (op1.length ?? 0)) {
// op2 after deletion
return {
...op2,
position: op2.position - (op1.length ?? 0),
};
} else {
// op2 inside deletion range
return {
...op2,
position: op1.position,
};
}
}
if (op1.type === 'insert' && op2.type === 'delete') {
if (op2.position < op1.position) {
// op2 before insertion
return op2;
} else if (op2.position >= op1.position + (op1.content?.length ?? 0)) {
// op2 after insertion
return {
...op2,
position: op2.position + (op1.content?.length ?? 0),
};
} else {
// op2 inside insertion
return {
...op2,
position: op1.position,
length: (op2.length ?? 0) + (op1.content?.length ?? 0),
};
}
}
if (op1.type === 'delete' && op2.type === 'delete') {
if (op2.position < op1.position) {
return op2;
} else if (op2.position >= op1.position + (op1.length ?? 0)) {
return {
...op2,
position: op2.position - (op1.length ?? 0),
};
} else {
// Overlapping deletes
return {
...op2,
position: op1.position,
length: Math.max(0, (op2.length ?? 0) - (op1.length ?? 0)),
};
}
}
return op2;
}
static apply(text: string, op: Operation): string {
if (op.type === 'insert') {
return text.slice(0, op.position) + op.content + text.slice(op.position);
} else if (op.type === 'delete') {
return text.slice(0, op.position) + text.slice(op.position + (op.length ?? 0));
}
return text;
}
}
// Usage
let text = 'Hello World';
const op1: Operation = { type: 'insert', position: 5, content: ',' };
const op2: Operation = { type: 'delete', position: 6, length: 5 };
// Client A applies op1
let textA = OperationalTransform.apply(text, op1); // "Hello, World"
// Client B applies op2 (concurrently)
let textB = OperationalTransform.apply(text, op2); // "Hello "
// Transform op2 against op1 for Client A
const op2Transformed = OperationalTransform.transform(op1, op2);
textA = OperationalTransform.apply(textA, op2Transformed); // "Hello,"
// Transform op1 against op2 for Client B
const op1Transformed = OperationalTransform.transform(op2, op1);
textB = OperationalTransform.apply(textB, op1Transformed); // "Hello,"
console.log(textA === textB); // true - convergence!
interface SyncMessage {
type: 'update' | 'sync_request' | 'sync_response';
clientId: string;
data?: any;
version?: number;
}
class SyncManager<T> {
private ws: WebSocket;
private clientId: string;
private crdt: LWWRegisterCRDT<T>;
private onUpdate: (data: T) => void;
constructor(
url: string,
initialData: T,
onUpdate: (data: T) => void
) {
this.clientId = this.generateClientId();
this.crdt = new LWWRegisterCRDT(initialData, this.clientId);
this.onUpdate = onUpdate;
this.ws = new WebSocket(url);
this.setupWebSocket();
}
private setupWebSocket() {
this.ws.onopen = () => {
console.log('Connected to sync server');
this.requestSync();
};
this.ws.onmessage = (event) => {
const message: SyncMessage = JSON.parse(event.data);
this.handleMessage(message);
};
}
private handleMessage(message: SyncMessage) {
switch (message.type) {
case 'update':
if (message.data && message.clientId !== this.clientId) {
const updated = this.crdt.merge(message.data);
if (updated) {
this.onUpdate(this.crdt.get());
}
}
break;
case 'sync_response':
if (message.data) {
this.crdt.merge(message.data);
this.onUpdate(this.crdt.get());
}
break;
}
}
update(data: T) {
this.crdt.set(data, this.clientId);
this.broadcast();
this.onUpdate(this.crdt.get());
}
private broadcast() {
const message: SyncMessage = {
type: 'update',
clientId: this.clientId,
data: this.crdt.getState(),
};
this.ws.send(JSON.stringify(message));
}
private requestSync() {
const message: SyncMessage = {
type: 'sync_request',
clientId: this.clientId,
};
this.ws.send(JSON.stringify(message));
}
private generateClientId(): string {
return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}
// Usage
const syncManager = new SyncManager(
'wss://sync.example.com',
'Initial data',
(data) => {
console.log('Data updated:', data);
}
);
syncManager.update('New data');
// LWW-Register: Single value, last write wins
const register = new LWWRegisterCRDT('initial', 'client-1');
// G-Set: Grow-only set (add only)
const gset = new GSet<string>();
// 2P-Set: Add and remove (once each)
const tpset = new TwoPhaseSet<string>();
// G-Counter: Increment-only counter
const gcounter = new GCounter();
// PN-Counter: Increment and decrement counter
const pncounter = new PNCounter();
// Last-Write-Wins: Use timestamp
if (remote.timestamp > local.timestamp) {
local = remote;
}
// Version Vector: Use vector clock
if (isGreaterThan(remote.vector, local.vector)) {
local = remote;
}
// Three-Way Merge: Compare base, local, remote
const merged = merge(base, local, remote);
// Full state sync
send({ type: 'full_sync', state: crdt.getState() });
// Delta sync (changes only)
send({ type: 'delta_sync', delta: crdt.getDelta() });
// Operational sync (operations)
send({ type: 'operation', op: { type: 'insert', ... } });
// Wrong: Ties cause non-deterministic results
if (remote.timestamp > local.timestamp) {
local = remote;
}
// What if timestamps are equal?
Why it's bad: Different clients may choose different values for concurrent updates
Better approach:
if (remote.timestamp > local.timestamp ||
(remote.timestamp === local.timestamp && remote.clientId > local.clientId)) {
local = remote;
}
// Wrong: Inefficient for large documents
socket.send(JSON.stringify({ type: 'update', data: fullDocument }));
Why it's bad: Wastes bandwidth, especially for small changes
Better approach:
// Send operations or deltas
socket.send(JSON.stringify({ type: 'operation', op: { type: 'insert', position: 5, content: 'x' } }));
// Wrong: Blindly overwrite
function update(data) {
this.data = data; // No version checking
}
Why it's bad: Lost updates when concurrent modifications occur
Better approach:
function update(data, version) {
if (version <= this.version) {
throw new Error('Conflict detected');
}
this.data = data;
this.version = version;
}
// Wrong: Blocking merge for large data
await merge(largeDocument); // Blocks UI
Why it's bad: UI freezes during merge
Better approach:
// Merge in Web Worker or use incremental merging
worker.postMessage({ type: 'merge', data: largeDocument });
Last Updated: 2025-10-18 Format Version: 1.0 (Atomic)