Local-First Software: Building Offline-Capable Apps with CRDTs
For twenty years the default architecture for interactive software has been server-authoritative: the browser or app is a thin view, the database is the source of truth, and every meaningful state change is a round trip. That model works beautifully when the network is fast, reliable, and cheap. It fails the moment any of those assumptions breaks, and it quietly taxes every interaction with latency the user can feel but rarely articulates.
Local-first software inverts the arrangement. The user’s device holds a full, durable copy of the data they care about. Reads and writes hit local storage first and render immediately. Replication to other devices and to a server happens in the background, as a synchronization concern rather than a UI concern. The hard engineering problem shifts from request handling to convergence: how do independent replicas, each of which has been edited offline, arrive at the same state without a central arbiter deciding every write?
This article covers the architecture, the data structures, the security trade-offs, and the operational realities of building local-first applications with conflict-free replicated data types (CRDTs).
What Local-First Actually Means
The term, popularized by Martin Kleppmann and colleagues, describes a set of properties rather than a single technology. A local-first application is:
- Fast by construction. Interaction latency is bounded by local disk and CPU, not by network distance.
- Multi-device. The same data appears on a laptop, a phone, and a tablet without a manual export step.
- Offline-capable. Full read and write access is available on a plane, in a basement, or on a flaky cellular link.
- Collaborative. Multiple people can edit the same objects concurrently without a lock server.
- Long-lived. Data outlives the vendor’s backend, because the client holds a complete copy.
- Private by default. End-to-end encryption becomes a realistic option when the server is a relay rather than a query engine.
- Owned by the user. Export is trivial when the client already holds the whole dataset.
You do not have to adopt all seven. Many production systems are partially local-first: a local write path with optimistic UI, backed by a server that still performs final validation. The interesting design work is deciding exactly where the boundary sits.
The Hard Part Is Not Offline, It Is Convergence
Adding a write-ahead queue and a retry loop gives you offline submission. It does not give you offline editing. The difference matters as soon as two replicas modify the same object while disconnected.
Server-authoritative systems handle conflict by serializing requests. The database applies writes in arrival order, and the last one wins. That is simple and often correct, but it requires a coordinator, which requires connectivity, which is the thing you were trying to avoid.
CRDTs remove the coordinator by making the merge function itself deterministic. Each replica applies operations in whatever order they arrive, and as long as every replica eventually receives every operation, the resulting state is identical everywhere. No consensus round, no leader election, no conflict-resolution prompt.
What CRDTs Cannot Do For You
Convergence is not the same as correctness. A CRDT guarantees replicas agree; it does not guarantee they agree on something your business logic considers valid. Operations that are impossible to express as a commutative, associative, idempotent merge remain hard:
- Global uniqueness. Two offline devices can both claim the username
ada. No merge function fixes that without a coordinator. - Invariants over collections. Preventing double-spend, enforcing a finite inventory count, or maintaining a non-negative balance requires serialization.
- Irreversible actions. Sending an email, charging a card, or dispatching a delivery cannot be merged away after the fact.
- Access revocation. Removing a collaborator from an encrypted document is a key-management problem, not a merge problem.
A practical heuristic: if an operation is append-only, commutative, or naturally last-write-wins, model it as a CRDT. If it must be serialized, validated against a global condition, or produce side effects, route it through a server and design the UI to show pending state honestly.
The CRDT Toolbox
Two Families
State-based CRDTs (CvRDTs) replicate whole states and merge them with a join operation that is commutative, associative, and idempotent. They tolerate arbitrary message loss and duplication, at the cost of shipping more bytes. Operation-based CRDTs (CmRDTs) replicate individual operations, which is far more bandwidth-efficient but requires causal delivery or buffering of out-of-order operations. Most production libraries are hybrids: operations on the wire, periodic state snapshots for catch-up.
The Types You Will Actually Use
- Counter (G-Counter, PN-Counter). Increments and decrements merge by summing per-replica components. Useful for likes, view counts, and inventory deltas — but not for anything requiring a floor at zero.
- Last-Writer-Wins Register. Each write carries a timestamp and an author; the highest timestamp wins. Simple, but it silently discards concurrent writes, so it is only appropriate for genuinely singular values like a status field or a display name.
- Multi-Value Register. Keeps all concurrent values instead of picking one, pushing resolution to the application. Good for fields where a silent overwrite would be data loss.
- Observed-Remove Set (OR-Set). Handles add and remove without the classic add-wins/remove-wins ambiguity, because removals only affect the additions that were actually observed. This is the workhorse for tags, memberships, and selections.
- Sequence CRDTs. RGA, YATA (the basis of Yjs), Fugue, and Logoot-family algorithms give you collaborative text and ordered lists. They are the most complex and the most performance-sensitive types in the toolbox.
- Maps and Trees. Composite types built recursively from the primitives above, giving you a document model that merges field by field rather than wholesale.
Clocks: Wall Time Is a Trap
Device clocks drift, are user-adjustable, and disagree across time zones and virtual machines. Using Date.now() as a tiebreaker in an LWW register is a reliable way to produce permanent data loss on a device with a skewed clock. Serious implementations use Lamport clocks or hybrid logical clocks (HLC), which combine a physical component for human-readable ordering with a logical counter that preserves causality when physical time is ambiguous.
Causality tracking — version vectors, dotted version vectors, or the per-operation dependency sets used by Automerge — is what lets a replica detect that it is missing a predecessor before applying an operation. Without it, out-of-order delivery produces states that converge but look wrong.
A Reference Architecture
A production local-first stack has five layers, each of which can be swapped independently.
- Durable local store. SQLite (native, WASM in the browser), IndexedDB, or an embedded key-value engine. It holds an append-only operation log plus a materialized view for queries.
- CRDT layer. Turns application mutations into mergeable operations and maintains the in-memory document. This is Yjs, Automerge, Diamond Types, or a bespoke implementation.
- Sync engine. Tracks what each peer has seen, computes deltas, handles reconnection, and applies backpressure. This is where most of the operational bugs live.
- Transport. WebSocket for the usual case, WebRTC data channels for peer-to-peer, HTTP long-poll as a fallback, and local transports like mDNS or Bluetooth for genuinely disconnected settings.
- Server. A relay, durable backup, authorization policy point, and compaction service. It should be treated as just another replica with extra responsibilities, not as the source of truth.
The write path is deliberately boring: durably persist first, then apply to the view, then replicate opportunistically.
// A minimal local-first write path, transport-agnostic
async function applyLocal(mutation) {
const op = { id: newId(), author: deviceId, clock: hlc.next(), payload: mutation };
await store.appendOp(op); // durable locally before anything else
store.applyToView(op); // UI updates immediately
outbox.push(op); // best-effort replication
}
async function onRemoteOps(ops) {
for (const op of sortByCausal(ops)) {
if (await store.hasOp(op.id)) continue; // idempotent apply
await store.appendOp(op);
store.applyToView(op);
}
}
Two properties are non-negotiable. First, the local append must succeed before the UI shows the change as durable; otherwise a crash loses acknowledged writes. Second, remote application must be idempotent, because duplicate delivery over unreliable transports is guaranteed, not exceptional.
Authorization, Privacy, and the Encryption Tension
Once clients hold full replicas, access control becomes the sharpest edge in the design. There are two incompatible goals:
- Server-enforced policy requires the server to read the data, validate operations, and reject unauthorized ones. It is auditable and familiar, but it destroys end-to-end encryption.
- End-to-end encryption keeps the server blind, which means it cannot validate anything. Authorization must be enforced cryptographically through per-document or per-workspace keys, and revocation requires key rotation plus re-encryption of the affected data.
Revocation is where naive designs collapse. Removing a collaborator from a shared CRDT does not retroactively hide history they already have. Realistic approaches include rotating the document key forward so new operations are unreadable by the removed party, accepting that past content remains known, and keeping genuinely sensitive material in separately keyed documents with short lifespans. If your threat model demands provable revocation of past access, local-first plus E2EE is the wrong tool.
Capability tokens are a useful middle ground. The server issues signed grants scoped to a workspace and an operation type, the client attaches them to outgoing operations, and peers verify signatures locally. It is more work than a row-level security check, but it keeps the server from being a required reader of every byte.
Schema Evolution Without a Migration Window
In a server-authoritative system, you migrate the database once and every client is immediately consistent. In a local-first system, clients run old code for months. A user may open an app they last updated before your redesign, then sync weeks of edits. Your format must tolerate that.
- Additive changes only, where possible. New fields with sensible defaults merge cleanly; renamed or repurposed fields do not.
- Preserve unknown fields. A reader that does not understand a field must round-trip it untouched. Dropping unknown keys silently corrupts other replicas’ data.
- Version the operation envelope, not just the payload. The sync engine needs to know whether it can interpret an operation before applying it.
- Treat compaction as a migration point. When you rewrite the log into a snapshot, you can normalize old shapes and drop deprecated fields in one pass.
- Test forward compatibility explicitly. Build a fixture from the previous release’s format and assert that the current code applies it correctly.
Testing Convergence
Convergence bugs are notoriously hard to reproduce by hand because they depend on interleavings. Treat them as a testing problem, not a debugging problem.
- Deterministic simulation. Run replicas in-process with a seeded scheduler that controls delivery order, duplication, and partition. If the seed reproduces the failure, you can step through it.
- Random operation fuzzing. Generate thousands of random operations across N replicas, apply them in shuffled order with duplicates, and assert that every replica’s final state is byte-identical.
- Invariant checkers. After convergence, verify domain invariants separately — total order preserved, no orphaned references, set membership consistent with the log.
- Captured log replay. Log real operation streams (with personal data scrubbed) and replay them in CI against every new build.
// Shape of a convergence property test
const ops = generateRandomOps({ replicas: 3, count: 500, seed });
const replicas = ops.map(shuffleWithDuplicates);
const states = replicas.map(applyAll);
assert.deepEqual(states[0].view(), states[1].view());
assert.deepEqual(states[1].view(), states[2].view());
Performance on Real Devices
CRDTs trade metadata for coordination, and that metadata grows. A collaborative text document accumulates tombstones and per-character identifiers. Without maintenance, memory and sync payloads grow without bound.
- Snapshot aggressively. Persist a compacted state and truncate the log past a watermark that all known peers have acknowledged.
- Batch and delta-encode. Send many small operations as one binary frame, and encode identifiers and clocks as varints rather than JSON.
- Constrain document size. Keep collaborative documents in the low megabytes. Partition large datasets into many documents by natural boundary — a project, a day, a record — so that sync scope stays small.
- Move merging off the main thread. A Web Worker for the CRDT layer keeps typing responsive in the browser; a background isolate or thread does the same on mobile.
- Avoid premature compaction. Rewriting history too eagerly can discard metadata still needed by a slow peer. Gate compaction on acknowledged watermarks.
- Measure sync cost per edit. If a single keystroke produces a kilobyte of replicated operations, no amount of transport tuning will save you.
The Framework Landscape
Two architectural philosophies dominate, and choosing between them is the first real decision.
| Approach | Examples | Core model | Good fit |
|---|---|---|---|
| Document CRDTs | Yjs, Automerge, Diamond Types | Rich mergeable documents with fine-grained text support | Collaborative editors, note-taking, whiteboards, design tools |
| Local SQL plus sync | ElectricSQL, PowerSync, Replicache-successor models, Zero | Local relational database, server syncs rows and resolves mutations | Applications with relational queries and existing SQL schemas |
| Offline-first sync platforms | Ditto, Couchbase Lite, Realm sync | Managed peer-to-peer or mesh replication | Field operations, retail, healthcare, low-connectivity deployments |
| Build your own | Custom op log plus CRDT types | Bespoke semantics tuned to one domain | Unusual merge rules or hard constraints on payload size and licensing |
The SQL-sync category is often the pragmatic answer for business applications: you keep the query model your team already knows, and the sync engine handles the transport. The trade-off is a weaker conflict model — many of these systems still resolve writes on the server, so their offline behavior is closer to queued submission than to true peer-to-peer convergence. Read the consistency documentation carefully before assuming otherwise.
When Local-First Is the Wrong Answer
- Financial ledgers and payments. Balances and transfers need serializable validation. A CRDT will converge on a state where money was spent twice.
- Global uniqueness constraints. Usernames, email addresses, and invoice numbers need a coordinator, or a reservation protocol that makes them effectively online.
- Strong regulatory retention. When an auditor requires an immutable, server-attested record of every change, client-held replicas complicate the story.
- Simple, low-value, always-connected apps. If your users are on reliable broadband and the data is small, the added complexity buys little.
- Heavy server-side computation. Analytics, machine learning, and search benefit from centralized data. Local-first pushes that work to the edge or forces a hybrid.
An Incremental Migration Path
Very few teams can rewrite their data layer in one release. A staged path works better:
- Local read cache and optimistic UI. Serve reads from a local store and apply writes optimistically, with the server still authoritative. You gain perceived speed and learn where the UI needs pending states.
- Durable local write log with an outbox. Queue mutations locally, retry with exponential backoff, and make them idempotent server-side via client-generated IDs. This is offline submission.
- Move mergeable entities to CRDTs. Identify the fields where last-write-wins is genuinely acceptable and convert those first. Text, tags, and checklists are usually the easiest wins.
- Promote the server to a replica. Once clients can merge from each other, the server becomes a relay, backup, and policy point rather than the writer of record.
- Add encryption and shrink the trust boundary. Only after the sync path is stable is it worth investing in per-document keys and capability tokens.
Operational Checklist
- Is the local append durable before the UI confirms the change?
- Are remote operations applied idempotently and in causal order?
- Do you have a watermark-aware compaction policy for logs and tombstones?
- Can a client from two releases ago sync without corrupting data?
- Does CI run a randomized convergence test on every commit?
- Are authoritative operations (payments, invitations, uniqueness) routed to a server with visible pending state?
- Have you measured sync payload per user-visible edit, not per request?
- Is there a documented, tested export path that gives users their full dataset without your servers?
Closing Thoughts
Local-first is not a framework choice so much as a commitment about where truth lives. Once the client holds a complete replica, you stop paying network latency on every interaction and start paying in merge complexity, storage, and authorization design. That trade is excellent for collaborative documents, field tools, creative software, and anything used in unreliable environments. It is a poor trade for ledgers, uniqueness, and workflows that require a single serializing authority.
The teams that succeed here treat sync as a first-class subsystem with its own tests, metrics, and failure modes — not as a library that gets imported and forgotten. Start with the merge semantics your domain can actually tolerate, prove convergence under fuzzing before you ship, and keep the authoritative path explicit for the operations that genuinely need one.

