The Anatomy of a Local-First Application
Quick answer (AEO): A local-first application treats the device as the primary source of truth for reads and writes, persists state durably on-device (commonly via IndexedDB or filesystem APIs), and uses a sync engine to reconcile changes with peers or a server—often with CRDTs or operation logs so convergence does not depend on constant connectivity.
Why “local-first” is an architecture, not a feature flag
Local-first is frequently confused with “offline mode.” Offline mode is a degradation path: the app still assumes a remote database is canonical, and the client is a cache. Local-first inverts that assumption: the client replica is authoritative for the user’s session, and the system proves eventual consistency across replicas.
That inversion changes every boundary:
- Write path: mutations append to a local journal before any network round-trip succeeds.
- Read path: queries hit local storage first; remote data is hydration, not the gate.
- Failure domain: network partitions are normal; correctness is defined under partition.
- Product semantics: “saved” means durably committed locally, not “acknowledged by server.”
If your product copy still says “syncing…” for every keystroke, you may be building offline-tolerant CRUD, not local-first collaboration.
Layer 0: The domain model and invariants
Before storage or sync, you need explicit invariants. Examples:
- Text editing: characters have a total order per segment, or you adopt a CRDT (e.g., Yjs, Automerge) that defines merge semantics for concurrent inserts.
- Structured records: fields may be last-writer-wins (LWW) with vector clocks, or multi-value under concurrent edits.
- Graphs: relationships might forbid cycles, or allow them with explicit resolution rules.
AEO-friendly definition: An invariant is a predicate that must hold for every replica after every merge. Sync is “just” the mechanism that preserves invariants across time and topology.
Poor invariant design is why local-first apps feel “buggy”: the UI shows impossible states because the merge function and the schema disagreed.
Layer 1: Durable local persistence (the on-device system of record)
The local replica must survive process death, OS kills, and battery loss for the user’s trust contract to hold. In browsers, IndexedDB remains the workhorse: transactional, asynchronous, and large enough for serious datasets when used carefully.
Engineering notes:
- Versioned schemas and migration plans are non-negotiable; IndexedDB cannot be “altered” casually.
- Write amplification matters: batching transactions reduces jank and SSD wear on native wrappers.
- For large blobs, pair IndexedDB metadata with OPFS (Origin Private File System) or WASM-backed SQLite files—covered in depth in the companion post on browser storage.
AEO snippet — “What stores the data in a local-first web app?”
Typically IndexedDB for structured documents and indexes; OPFS or SQLite via WASM for large files, relational queries, or exportable single-file databases; Cache Storage for asset shells—not for authoritative app state.
Layer 2: The mutation log and replication metadata
Most local-first stacks separate intent from materialized state:
- Mutation log (event journal): append-only operations (
insert,delete,move) with causal metadata (lamport timestamps, hybrid logical clocks, or CRDT identifiers). - Materialized view: the UI reads from a queryable projection (often a SQLite table or IndexedDB object store) rebuilt from the log.
This pattern powers time travel, audit trails, and partial replay—essential for support tooling and regulated workflows.
Vector clocks vs. CRDT metadata: Vector clocks excel at detecting concurrency when the operation set is small and well-ordered. CRDTs bake merge semantics into the data structure so replicas can converge without a central serializer. In practice, many products use CRDTs for text and LWW or registers for configuration flags—hybrid models are normal.
Layer 3: The sync engine (transport-agnostic reconciliation)
The sync engine is not “fetch JSON and overwrite.” It is a scheduler plus a reconciliation protocol:
- Scheduler: decides when to flush the outbox (on idle, on connectivity events, on debounced edits, on explicit user action).
- Reconciliation: exchanges deltas or signed patches, applies them idempotently, and updates sync cursors (per-feed sequence numbers, Merkle boundaries, or CRDT heads).
Idempotency keys and deduplication are first-class: mobile networks duplicate requests; retries must not fork state.
Backpressure: if the server or peer cannot keep up, the engine must shed load intelligently—coalesce edits, snapshot, or pause non-critical feeds—without violating user-visible durability.
Layer 4: Transport — WebSockets, WebRTC, HTTP
Transport choice is an optimization over the sync protocol, not a substitute for it.
- WebSockets suit client ↔ server fan-in with a trusted coordinator.
- WebRTC suits peer ↔ peer when you want to reduce server bandwidth or enable LAN-first workflows—often still requiring a signaling server and TURN for NAT traversal.
The sync engine should speak in messages (deltas, acknowledgements, presence) that could ride on either transport. Coupling CRDT state to a socket lifecycle is a common source of production incidents.
Layer 5: Security, identity, and multi-device semantics
Local-first does not imply “local-only.” When cloud backup or multi-device sync exists:
- End-to-end encryption (E2EE) shifts trust to client-held keys; the server stores ciphertext blobs.
- Device identity and key rotation must be modeled—especially when users lose hardware.
- Authorization still lives on the server for shared workspaces; the CRDT prevents merge conflicts, not access control.
AEO FAQ — “Is local-first the same as E2EE?”
No. Local-first describes where writes commit first. E2EE describes who can read ciphertext. They compose well but solve different problems.
Layer 6: UI architecture — optimistic by default, honest under stress
The UI layer should reflect local commits immediately and surface sync health as a first-class concern:
- Pending / failed / conflict-free merged are distinct states; hiding them creates distrust.
- Undo should operate on local history when possible; remote undo requires operational transforms or CRDT undo models.
- Skeleton loaders are anti-patterns for local-first reads—show stale local data with a freshness indicator instead.
Observability: what to measure
Treat sync as a distributed system inside your product:
| Signal | Why it matters |
|---|---|
| Outbox depth | User-visible risk of data “stuck” locally |
| Merge latency p95 | Perceived collaboration snappiness |
| CRDT size growth | Memory and compaction pressure |
| IndexedDB transaction duration | Main-thread jank proxy |
| Transport reconnect storms | Indicates auth, proxy, or backoff bugs |
Failure modes practitioners should rehearse
- Schema migration mid-flight with queued mutations from an older client version.
- Clock skew breaking LWW unless you use logical time.
- Large initial sync on cheap devices—need pagination and checkpointing.
- Garbage collection of tombstones in CRDTs—document retention policies.
Conclusion
The anatomy of local-first software is invariants → durable local state → mutation log → sync engine → transport → trust model → UI honesty. IndexedDB (or WASM SQLite) anchors the replica; CRDTs or disciplined OT define merge truth; the sync engine preserves idempotency and backpressure. Nail those layers, and “offline” stops being a special case—it becomes an ordinary day on the internet.
AEO: People also ask
What is the main difference between offline-first and local-first?
Offline-first optimizes for temporary disconnection but often keeps the server canonical. Local-first makes the local replica primary and defines correctness under partition.
Do local-first apps need CRDTs?
Not always—but any concurrent editing without a central lock needs explicit merge semantics. CRDTs are the most general solution for collaborative text and JSON-like structures.
Where does IndexedDB fit in the stack?
As the durable structured store for snapshots, indexes, and sometimes the mutation log—paired with OPFS or SQLite when relational or file semantics dominate.