Local-First Architecture: Implementing Offline Conflict Resolution with CRDTs
· Local-First · CRDTs · Offline-First · Distributed Systems · TypeScript
Explore how to build resilient local-first applications using Conflict-Free Replicated Data Types (CRDTs) to handle offline mutations and state synchronization without central server coordination.
更新情報を受け取る
新しい記事を公開したときに短いお知らせを送ります。メールまたはブラウザの購読情報は通知配信のためだけに保存され、いつでも解除できます。アカウントや追跡用プロフィールは不要です。
Introduction to Local-First Sync
Local-first software prioritizes local storage and execution over server dependency, ensuring applications remain fully functional without a network connection. However, shifting the source of truth from a centralized database to multiple client devices introduces difficult distributed systems challenges. When clients operate offline, mutate local state independently, and later reconnect, their state divergence must be resolved deterministically without dropping user data or requiring manual intervention.
Traditional approaches like last-write-wins (LWW) timestamps fail in practice due to clock drift and the loss of concurrent intent. Operational Transformation (OT) handles text collaboration well but requires a central server to sequence operations, violating the peer-to-peer goals of local-first architectures. Conflict-Free Replicated Data Types (CRDTs) solve this by defining mathematical convergence guarantees at the data structure level, allowing any two replicas to merge independently computed states into an identical outcome.
Choosing State-Based vs. Operation-Based CRDTs
CRDT implementations broadly fall into two execution models: state-based (CvRDT) and operation-based (CmRDT). Understanding their network assumptions and storage profiles is critical before designing your persistence layer.
State-based CRDTs transmit the entire local state to peers during synchronization. The remote peer merges the incoming state with its own using a defined join semi-lattice operation. This model is straightforward to implement over unreliable networks because message loss or duplication does not corrupt state. The primary trade-off is network payload size; as datasets grow, transmitting full states becomes prohibitively expensive, requiring delta-state optimizations where only state changes since the last sync vector are transferred.
Operation-based CRDTs transmit individual mutation operations rather than full snapshots. This drastically reduces bandwidth consumption, but it requires the underlying transport layer to provide causal delivery guarantees with exactly-once semantics. If an operation arrives out of order or is duplicated, convergence breaks. For most web and mobile local-first applications, state-based CRDTs with delta-state compression offer a more resilient operational profile against intermittent network drops.
Implementing a Grow-Only Set and LWW-Map in TypeScript
To ground these concepts, let us examine a simplified TypeScript implementation of a state-based Observed-Remove Set (OR-Set) or a Last-Write-Wins Map (LWW-Map). An LWW-Map associates keys with values where each entry carries a physical timestamp and a client identifier to break ties deterministically.
type Timestamp = number;
type ClientID = string;
interface VersionedValue<T> {
value: T;
timestamp: Timestamp;
clientId: ClientID;
}
export class LWWMap<K, V> {
private store = new Map<K, VersionedValue<V>>();
private readonly clientId: ClientID;
constructor(clientId: ClientID) {
this.clientId = clientId;
}
public set(key: K, value: V, timestamp: Timestamp = Date.now()): void {
const existing = this.store.get(key);
if (!existing || timestamp > existing.timestamp || (timestamp === existing.timestamp && this.clientId > existing.clientId)) {
this.store.set(key, { value, timestamp, clientId: this.clientId });
}
}
public get(key: K): V | undefined {
return this.store.get(key)?.value;
}
public merge(remoteStore: Map<K, VersionedValue<V>>): void {
for (const [key, remoteEntry] of remoteStore.entries()) {
const localEntry = this.store.get(key);
if (!localEntry) {
this.store.set(key, remoteEntry);
} else if (remoteEntry.timestamp > localEntry.timestamp) {
this.store.set(key, remoteEntry);
} else if (remoteEntry.timestamp === localEntry.timestamp && remoteEntry.clientId > localEntry.clientId) {
this.store.set(key, remoteEntry);
}
}
}
public serialize(): Array<[K, VersionedValue<V>]> {
return Array.from(this.store.entries());
}
}In this implementation, the merge method evaluates incoming remote entries against local entries. If timestamps match, the lexicographically larger clientId breaks the tie, ensuring all peers execute the exact same conditional branch without coordination.
Handling Storage, Compaction, and Garbage Collection
Local-first systems store mutable states in embedded databases like SQLite, IndexedDB, or Realm. Storing raw CRDT metadata per record can bloat storage capacity if left unchecked. For instance, an OR-Set requires tombstone markers to track deleted elements so that concurrently added items are not resurrected upon synchronization.
Garbage collection of tombstones is non-trivial. If you drop a tombstone too early, a lagging offline client might sync later and re-add an element that was explicitly deleted, causing phantom resurrections. Production systems address this by maintaining vector clocks or version vectors across clients. Once all known peers acknowledge they have seen a specific vector state, safe compaction routines can prune historical tombstones and delta logs.
Network Topologies and Sync Engines
Because CRDTs are mathematically agnostic to network topology, you can synchronize state across multiple channels simultaneously:
- Peer-to-peer over local WebRTC data channels or Bluetooth Low Energy.
- Client-server relay via WebSockets to a central PostgreSQL or minimal storage node.
- Exported snapshot files shared via local file systems or object storage.
A robust sync engine decouples the local database writes from the transport layer. Mutations are appended to a local transaction log immediately, guaranteeing instantaneous UI responsiveness. A background sync worker reads the unacknowledged log entries, packages them into delta payloads, and attempts transmission over whichever transport is currently active.
Conflict Resolution Edge Cases and Pitfalls
While CRDTs guarantee convergence, mathematical convergence does not equal semantic correctness. If User A updates a user profile username to 'Alice' and User B updates the same field to 'Alicia' at the same time, an LWW strategy will silently pick one based on timestamps and client IDs. The system converges, but User A's intent is lost without user-facing conflict resolution prompts.
To mitigate semantic conflicts, structure your domain model defensively:
- Design immutable event-sourcing logs for financial or audit-heavy domains rather than mutable key-value maps.
- Split monolithic documents into smaller, independent CRDT fragments to minimize concurrent write collisions on the same keys.
- Provide explicit UI merge interfaces when concurrent edits affect business-critical fields.
Verification and Testing Strategies
Testing distributed state machines requires simulating network partitions, packet reordering, and arbitrary delays. Unit tests should verify property-based invariants:
- Commutativity: $State_A \cup State_B == State_B \cup State_A$
- Associativity: $(State_A \cup State_B) \cup State_C == State_A \cup (State_B \cup State_C)$
- Idempotency: $State_A \cup State_A == State_A$
Automated fuzz testing can generate random interleavings of offline writes across simulated client clusters, asserting that every client arrives at an identical byte-for-byte state upon synchronization completion. Incorporating these verification steps into your continuous integration pipeline prevents regression bugs in your sync protocol.
