Web Engineering ·

Local-First Software Architecture: CRDTs, Sync Engines, and Offline-Capable Web Apps

A deep dive into local-first architecture for web apps, covering CRDTs, sync engine design, conflict resolution without a central authority, storage primitives, and honest tradeoffs versus traditional client-server.

Local-First Software Architecture: CRDTs, Sync Engines, and Offline-Capable Web Apps

Most web apps treat the server as the source of truth and the browser as a thin client. Every interaction round-trips to the server. Offline is an error state. Latency is a constant tax.

Local-first software inverts this. Data lives on the device. The server is a sync peer, not an authority. Reads are instant because they hit local storage. Writes commit immediately and propagate when connectivity allows. Conflict resolution happens without a central arbiter, using data structures that merge deterministically.

This architecture is not universally appropriate. For some apps it is a genuine upgrade. For others it is months of complexity for minimal benefit. This article covers the technical foundations, concrete TypeScript implementations, and the production tradeoffs that determine which category your app falls into.

The Local-First Philosophy

The core shift is about data ownership and read/write latency. In a traditional app, the client asks the server for data on every navigation, waits for network round-trips to commit mutations, and shows loading spinners as the dominant UI state. In a local-first app, the client reads from a local replica, commits to that replica first, and syncs to peers in the background.

The four properties that define local-first apps:

  • Local reads and writes. All data access hits a local store first. No network required to read or mutate state.
  • Eventual consistency. The local replica and remote peers converge to the same state given the same set of operations, even if those operations arrived in different orders.
  • Collaboration without coordination. Multiple clients can write concurrently without locking. Merges are deterministic.
  • User-owned data. The canonical copy lives on the user’s device, not only in a cloud database the user cannot access without an internet connection.

The mental model shift matters: you are building a distributed system where every client is a full node, not a stateless consumer.

CRDTs: Conflict-Free Replicated Data Types

CRDTs are the data structures that make local-first merging tractable. A CRDT is a data type whose merge operation is commutative, associative, and idempotent. Any two replicas can be merged in any order and you always get the same result.

There are two families: operation-based (op-based) and state-based. Op-based CRDTs broadcast individual operations; state-based CRDTs broadcast full state and merge it. State-based approaches are simpler to reason about but more bandwidth-intensive. Op-based approaches require reliable delivery of every operation but are more efficient.

G-Counter

The simplest CRDT. Each node has its own counter slot. Incrementing adds to the local slot. Merging takes the max of each slot across replicas.

type NodeId = string;

interface GCounter {
  counts: Record<NodeId, number>;
}

function increment(counter: GCounter, nodeId: NodeId): GCounter {
  return {
    counts: {
      ...counter.counts,
      [nodeId]: (counter.counts[nodeId] ?? 0) + 1,
    },
  };
}

function merge(a: GCounter, b: GCounter): GCounter {
  const allNodes = new Set([
    ...Object.keys(a.counts),
    ...Object.keys(b.counts),
  ]);
  const merged: Record<NodeId, number> = {};
  for (const node of allNodes) {
    merged[node] = Math.max(a.counts[node] ?? 0, b.counts[node] ?? 0);
  }
  return { counts: merged };
}

function value(counter: GCounter): number {
  return Object.values(counter.counts).reduce((sum, n) => sum + n, 0);
}

A PN-Counter (positive-negative) extends this with two G-Counters to support decrement.

Last-Write-Wins Register

For scalar values where you want the most recent write to win, attach a logical timestamp (Lamport clock or hybrid logical clock) to each value and take the higher timestamp on merge.

interface LWWRegister<T> {
  value: T;
  timestamp: number; // Lamport clock tick
  nodeId: NodeId;    // Tiebreaker
}

function write<T>(
  reg: LWWRegister<T>,
  newValue: T,
  localClock: number,
  nodeId: NodeId
): LWWRegister<T> {
  return {
    value: newValue,
    timestamp: localClock + 1,
    nodeId,
  };
}

function merge<T>(a: LWWRegister<T>, b: LWWRegister<T>): LWWRegister<T> {
  if (a.timestamp > b.timestamp) return a;
  if (b.timestamp > a.timestamp) return b;
  // Deterministic tiebreak on nodeId
  return a.nodeId > b.nodeId ? a : b;
}

LWW registers discard concurrent writes. If two nodes write different values at the same Lamport tick (which can happen with wall clock timestamps in distributed systems), one silently wins. This is often acceptable for settings like “user’s display name” but wrong for collaborative document editing.

OR-Set (Observed-Remove Set)

Plain sets break in distributed systems: add on node A and remove on node B with messages arriving out of order, and you get inconsistent results. OR-Sets solve this by tagging each element with a unique identifier at add time. Removes only remove specific tagged instances, not all instances of an element value. The merge union combines all element-to-tag mappings and all tombstones across replicas.

The non-obvious production detail: tombstones grow unboundedly without compaction. Once all known peers have acknowledged a tombstone, the element and its tombstone entry can be safely removed. Design the compaction pass before you need it, not after the set has grown to tens of thousands of tombstones.

CRDT Maps

A map CRDT is per-key LWW registers or per-key OR-Sets depending on value semantics. Libraries like Automerge implement a full document model where every key in a nested map is tracked independently with vector clocks per key. Building this yourself is possible but rarely worth it.

Sync Engine Design

The CRDT data structures handle merge semantics. The sync engine handles transport: which operations to send to which peers, in what order, with what guarantees.

Operation-Based Sync

Each local mutation produces an operation (op). Ops are appended to a local log with a vector clock. Sync means exchanging ops you have that the peer does not.

type VectorClock = Record<NodeId, number>;

interface Operation {
  id: string;          // Unique op ID: `${nodeId}-${localSeq}`
  nodeId: NodeId;
  clock: VectorClock;  // Clock at time of op
  type: string;
  payload: unknown;
}

interface SyncState {
  knownClocks: Record<NodeId, VectorClock>; // What we know each peer has seen
}

// Determine which ops to send to a peer given what the peer has acknowledged
function opsToSend(
  log: Operation[],
  peerAcknowledgedClock: VectorClock,
  localNodeId: NodeId
): Operation[] {
  return log.filter((op) => {
    const peerSeenUpTo = peerAcknowledgedClock[op.nodeId] ?? 0;
    const opSeq = parseInt(op.id.split("-")[1], 10);
    return opSeq > peerSeenUpTo;
  });
}

Op-based sync requires that every op is delivered exactly once. A dropped op leaves replicas permanently diverged. In practice this means:

  • Store ops durably before applying them locally.
  • Use acknowledged delivery (the peer sends back which ops it has processed).
  • Never garbage-collect an op until all known peers have acknowledged it.

State-Based Sync

State-based sync sends the full CRDT state (or a delta of it) rather than individual ops. The recipient merges the incoming state with its local state. Order of delivery does not matter.

interface SyncMessage<S> {
  nodeId: NodeId;
  state: S;
  stateVector: VectorClock; // What the sender has seen
}

async function handleIncomingState<S>(
  localState: S,
  message: SyncMessage<S>,
  mergeFunc: (a: S, b: S) => S,
  persistFunc: (s: S) => Promise<void>
): Promise<S> {
  const merged = mergeFunc(localState, message.state);
  await persistFunc(merged);
  return merged;
}

State-based sync is robust to dropped messages and reordering. The cost is bandwidth: sending a 500KB document state on every sync is expensive on mobile. Delta-state CRDTs address this by sending only what changed since a given vector clock.

Existing Libraries

Building CRDT primitives from scratch is viable for specific data structures. For full document models, you almost certainly want a library.

Yjs is op-based, implements a YATA (Yet Another Transformation Approach) algorithm for shared text, and has bindings for ProseMirror, Monaco, and CodeMirror. The network layer is left to you via providers: y-websocket, y-webrtc, and y-indexeddb are the common choices. Yjs is mature and performs well for collaborative text and structured documents.

Automerge implements a different op-based CRDT with richer document semantics. Automerge 2 rewrote the core in Rust (compiled to WASM), which improved performance substantially. The document model supports arbitrary nesting. The Automerge repo server provides a sync server implementation.

ElectricSQL takes a different approach: it syncs a Postgres database to SQLite in the browser using the Electric sync protocol. You write standard SQL queries locally, and ElectricSQL handles the partial replication and conflict resolution at the row level using LWW semantics. This is a strong fit if your backend is already Postgres and you want local reads without building a custom CRDT layer.

CR-SQLite extends SQLite with CRDT semantics at the table level. You declare tables as CRDTs (LWW per column, or fractional indexing for ordered lists), and crsql_changes() returns a changelog you sync between replicas. CR-SQLite ships as a SQLite extension and runs in browsers via WASM.

Local Storage: IndexedDB and OPFS

Two browser storage primitives matter here.

IndexedDB is a transactional key-value store with cursor-based queries. It is asynchronous, survives page reloads, and has a storage quota determined by the browser (typically 60% of available disk, capped at a few GB before prompting the user). The API is verbose; libraries like idb wrap it in promises. Yjs’s y-indexeddb provider persists the document to IndexedDB automatically. IndexedDB is appropriate for storing CRDT state, op logs, and document metadata.

OPFS (Origin Private File System) is a newer API that gives a web app access to a private, sandboxed portion of the filesystem. It is faster than IndexedDB for large sequential writes because it bypasses the structured data overhead. SQLite WASM (the official SQLite port) uses OPFS for persistence. If you are running CR-SQLite or any SQLite-backed local store in the browser, OPFS is the right storage backend. OPFS has limited browser support compared to IndexedDB but all major modern browsers support it now.

// Checking OPFS availability and opening a file
async function openOPFSFile(filename: string): Promise<FileSystemFileHandle> {
  const root = await navigator.storage.getDirectory();
  return root.getFileHandle(filename, { create: true });
}

// Using OPFS sync access handle (only available in Web Workers)
async function writeSQLitePage(
  fileHandle: FileSystemFileHandle,
  offset: number,
  data: Uint8Array
): Promise<void> {
  const syncHandle = await fileHandle.createSyncAccessHandle();
  syncHandle.write(data, { at: offset });
  syncHandle.flush();
  syncHandle.close();
}

The sync access handle (used in Web Workers) is why SQLite WASM performs well: it gives SQLite a synchronous file I/O interface inside a Worker, which is what SQLite expects.

Conflict Resolution Without a Central Server

Central servers resolve conflicts through serialization: every write goes through a single authoritative node that assigns a total order. Local-first systems cannot do this. Conflict resolution must be deterministic given only local information.

The practical strategies, in order of complexity:

Last-write-wins per field. Use hybrid logical clocks (HLC) rather than wall clocks. HLCs combine a physical timestamp with a logical counter. They advance on every event and are corrected when you receive a higher HLC from a peer. This gives you causally consistent timestamps that are also monotonic.

interface HLC {
  wallTime: number; // ms since epoch
  logical: number;  // Tie-breaker
  nodeId: NodeId;
}

function hlcTick(current: HLC, nodeId: NodeId): HLC {
  const now = Date.now();
  if (now > current.wallTime) {
    return { wallTime: now, logical: 0, nodeId };
  }
  return { wallTime: current.wallTime, logical: current.logical + 1, nodeId };
}

function hlcReceive(local: HLC, remote: HLC, nodeId: NodeId): HLC {
  const wallTime = Math.max(Date.now(), local.wallTime, remote.wallTime);
  if (wallTime === local.wallTime && wallTime === remote.wallTime) {
    return {
      wallTime,
      logical: Math.max(local.logical, remote.logical) + 1,
      nodeId,
    };
  }
  if (wallTime === local.wallTime) {
    return { wallTime, logical: local.logical + 1, nodeId };
  }
  if (wallTime === remote.wallTime) {
    return { wallTime, logical: remote.logical + 1, nodeId };
  }
  return { wallTime, logical: 0, nodeId };
}

Operational transform / CRDT for text. For collaborative text editing, character-level LWW does not work because insertion position depends on the current document state at the time of the insert. Yjs and Automerge both solve this correctly. Do not roll your own text CRDT.

Intent preservation. For some data types, you want to merge based on what the user intended, not just the timestamp. A task checklist where two users both complete the same item should result in the item being completed, not a conflict. Model the operation as “mark complete” rather than “set isComplete = true” and apply idempotent operations.

Production Tradeoffs

DimensionLocal-FirstTraditional Client-Server
Read latencySub-millisecond (local)50-500ms (network)
Write latencySub-millisecond (optimistic)50-500ms (network)
Offline supportFull read/writeNone or stale read-only
Initial loadCold start requires full syncPaginated server fetch
Conflict complexityRequires CRDT disciplineSerialized by server
Storage limitsBrowser quota (~1-2GB practical)Server-side, unlimited
DebuggingDistributed state, hard to inspectCentralized, SQL queryable
SecurityData on-device; harder to enforce ACLsServer-side ACL enforcement trivial
Real-time collabNative (CRDTs merge automatically)Requires WebSocket + OT server

Storage limits are a real constraint. IndexedDB quota varies by browser. Chrome allows up to 60% of available disk, but the browser can evict data under storage pressure. OPFS is more durable but still not a substitute for a server-side backup. For local-first apps with large datasets, you need a sync server that functions as a durable backup, not just a relay.

Initial sync can be expensive. A new device needs to download the full op log or state to initialize. For a collaborative document with years of history, this can be hundreds of megabytes. Strategies: snapshots (save the full state at a point in time, then only replay ops after that snapshot), lazy loading (sync only the documents the user opens), and partial replication (sync subsets of the dataset based on user context).

Debugging distributed state is genuinely harder. You cannot SELECT * FROM table on the server and see the ground truth. Each device has its own replica in a potentially different state. You need tooling: state export, op log inspection, and comparison utilities. Automerge has a debug view. Yjs documents can be serialized and inspected. CR-SQLite exposes its changelog via crsql_changes(). Plan for this from the start.

ACL enforcement is more complex. In a traditional app, the server validates every write and can reject unauthorized mutations. In a local-first system, the client writes locally first and syncs later. You need to either enforce ACLs at sync time (the server rejects ops that violate permissions and the client must reconcile) or use encryption to prevent clients from reading data they should not have. Both are non-trivial.

When Local-First Is the Wrong Choice

Local-first adds real complexity. Do not reach for it unless you have a clear reason.

Use traditional client-server if:

  • Your app is primarily read-heavy with infrequent writes (blogs, dashboards, catalogs). Caching handles latency; CRDTs add nothing.
  • Your data model has strong consistency requirements that conflict with eventual consistency. Financial ledgers, inventory with hard constraints, booking systems with limited capacity: these need serializable writes, not CRDTs.
  • You have a small team with limited distributed systems experience. The operational overhead of debugging diverged replicas and implementing correct sync protocols is non-trivial.
  • Your users access from a single device or always have reliable connectivity. The offline capability is the main benefit; if your users are always online, you are paying the complexity cost without capturing the value.

Local-first fits well when:

  • The app is collaborative and users need real-time or near-real-time sync across devices without a perceptible loading state.
  • Offline capability is a hard requirement: field work, travel, unreliable connectivity environments.
  • You are building a productivity tool where perceived latency (especially for mutations) is a primary quality dimension.
  • Your data model maps cleanly to CRDT primitives: documents, lists, sets, counters, key-value stores.

The Non-Obvious Production Concerns

Tombstone growth. OR-Sets and other deletion-aware CRDTs accumulate tombstones indefinitely without compaction. On a long-lived app this becomes a memory and storage problem. Design your compaction strategy before you need it.

Schema evolution. When you change the structure of a CRDT document, old ops in the log may no longer apply cleanly. Automerge handles schema evolution better than most because it stores the full op history. CR-SQLite requires more manual migration coordination. Test upgrade paths from older op logs before rolling out schema changes.

Op log garbage collection. To garbage-collect old ops safely, every known peer must have acknowledged them. If a peer goes offline for months and then reconnects, you cannot garbage-collect ops it has not yet received. You need a “catch-up” mechanism and a policy for peers that have been offline long enough that you consider them a new device rather than a peer in the sync graph.

Sync protocol versioning. The sync message format will change. Version it from day one. Your sync server needs to speak multiple protocol versions to handle clients that have not upgraded.

Observability. Instrument: sync lag per client (time between local write and server acknowledgment), merge conflict rate, tombstone count per document, failed sync attempts, and storage usage. These metrics will tell you when a client has diverged or when tombstone growth is approaching a threshold.

A Practical Starting Point

If you want to experiment with local-first without building everything from scratch:

For document-centric collaboration (text editors, whiteboards, structured documents): start with Yjs. It has the largest ecosystem, mature providers, and clear upgrade paths.

For SQL-native apps where your backend is Postgres: ElectricSQL gives you partial replication to SQLite with minimal CRDT surface area. You write SQL; the library handles sync.

For apps where you need a full local SQLite database in the browser with custom merge semantics: CR-SQLite plus OPFS. More setup, more control.

For pure CRDT experimentation or building custom data structures: implement the primitives yourself. The G-Counter and LWW register are small enough to own. For OR-Sets and maps, lean on a library.

The architecture is genuinely worth the complexity for the right class of apps. The key is being honest about whether your app is in that class before you build the sync engine.


Local-first is not a replacement for the server. It is a different topology: the client becomes a peer with full read/write access to a local replica, and the server becomes a sync mediator and durable backup. The tradeoff is straightforward when you state it plainly: you get lower latency, offline capability, and natural collaborative semantics, at the cost of distributed state complexity, harder ACL enforcement, and non-trivial initial sync design. For productivity tools and collaborative apps, that tradeoff is worth it. For the rest, a well-cached traditional API is simpler and easier to operate.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.