Local-First, State of the Art
Everything you need to get caught up on local-first software — the theory, the sync-engine landscape, the hard unsolved parts, and how to actually pick a stack for a web or mobile app.
01What local-first actually means
The term comes from Ink & Switch's 2019 essay Local-First Software (and Kleppmann et al.'s accompanying paper). The core inversion: the primary copy of your data lives on the user's device, and the server — if there is one — is a sync peer, not the source of truth. The essay defines seven ideals: instant response, multi-device, offline, collaboration, longevity ("your data outlives the app"), privacy, and user control.
It's worth separating three things that get conflated:
- Offline-first — a cache-and-queue strategy for cloud apps. The server still owns the data. (Trello works this way.)
- Local-first — the device owns the data; sync is a replication problem between equals. (Linear, Actual, Anytype.)
- The pragmatic middle — most commercial "local-first" apps in 2026: a full local replica with optimistic writes, but a server that remains authoritative for permissions and arbitration. This is where sync engines live, and it's where most of the recent energy is.
Why should an advanced engineer care beyond the ideology? Two reasons that show up over and over in the source material. First, performance: reading from a local store means every interaction is a sub-millisecond query, which is how Linear and Superhuman (the 100 ms rule) feel the way they do. Second, architecture simplification: once a sync engine owns data movement, you delete the entire fetch/cache/invalidate/loading-spinner layer of a conventional app — the argument made in Are Sync Engines the Future of Web Applications? and Riffle's reactive-relational essay.
For balance, read Why Haven't Local-First Apps Taken Off? (2025): the honest answer is that you're taking on distributed-systems complexity (migrations, conflicts, partial replication, auth) that the cloud model centralizes away, and until recently the tooling made you pay that cost yourself. The tooling is what changed — that's most of what this post covers.
02The architecture spectrum
Every local-first system answers the same four questions: where does data live locally, how do writes propagate, who resolves conflicts, and who enforces permissions? The answers place you on a spectrum:
Moving right buys you autonomy and resilience; it costs you central control, easy auth, and simple schema evolution. The single most useful framing in the whole reading list is from You Might Not Need a CRDT: if you have a server anyway, let it be the arbiter and your data structures get radically simpler. Figma famously runs on this principle — per-property last-writer-wins arbitrated by the document server, not a CRDT (How Figma's Multiplayer Works).
Most production systems today sit in the second column, borrow CRDT techniques where cheap (see fractional indexing, below), and reserve full CRDTs for text.
03Conflict resolution: CRDTs, OT, and neither
When two offline devices edit the same data, something must merge the results. Three schools:
Operational Transformation (OT)
The Google Docs approach: transform concurrent operations against each other, usually requiring a central server to sequence them. Mature but notoriously hard to implement correctly — play with the interactive OT visualization to see why. Joseph Gentle, who built OT systems at Google Wave, wrote the definitive conversion story: I Was Wrong. CRDTs Are the Future.
CRDTs
Conflict-free Replicated Data Types: data structures whose merge is commutative, associative, and idempotent — replicas converge no matter what order updates arrive in, no central sequencer required. Start with A Gentle Introduction to CRDTs, then Jake Lazaroff's interactive intro (build a LWW register in ~100 lines), then Matt Weidner's Designing Data Structures for Collaborative Apps for how to think in CRDT primitives. For depth, Bartosz Sypytkowski's 22-post series is the long course.
Two objections have historically kept CRDTs out of production, and both are now largely answered:
- "They're slow and memory-hungry." CRDTs Go Brrr documents a 5000× speedup over a naive implementation; Kevin Jahns' benchmarks show Yjs handling huge documents fine; Automerge 3 cut memory ~10×. Modern engines (Yjs, Loro, Diamond Types, json-joy) are Rust/WASM-fast.
- "Rich text and lists interleave badly." Solved-ish: Peritext (rich-text CRDT), Kleppmann's CRDTs: The Hard Parts covers move operations and interleaving. See mu-txt for a shipping Peritext-based editor built on json-joy.
Server reconciliation (neither)
The pragmatic third way: clients send intents (mutations), the server applies them in order against authoritative state, clients rebase their optimistic state on the result. This is Figma's model, Linear's model, and the model of Replicache/Zero-style engines. You lose peer-to-peer merges; you gain centralized validation, permissions, and dramatically simpler reasoning.
Rule of thumb distilled from the list: collaborative free-form text → CRDT (Yjs/Loro/Automerge). Structured app data with a server → server reconciliation or a sync engine. True P2P/E2EE requirement → CRDT, because no server can arbitrate what it can't read. James Long's CRDTs for Mortals (with a full reference app) shows the hybrid: plain SQLite + per-column LWW timestamps — the architecture behind Actual Budget.
04The sync-engine landscape
This is where the field moved fastest in 2024–2026. The players cluster into four families:
Postgres-replication engines — keep your backend, sync it down
| Engine | Model | Notes |
|---|---|---|
| ElectricSQL | Partial replication of Postgres via "shapes" over HTTP | Read-path sync engine; writes go through your API. Pairs with PGlite (Postgres-in-WASM) or TanStack DB on the client. |
| PowerSync | Postgres/MongoDB/MySQL → local SQLite, dynamic sync rules | Strongest mobile story (Flutter, React Native, Kotlin, Swift). Writes via upload queue to your backend. |
| Zero | Query-driven sync: client subscribes to ZQL queries, server maintains them incrementally | From the Replicache team (Replicache itself is in maintenance mode). Server reconciliation with custom mutators; probably the most-watched engine right now. |
Batteries-included platforms — Firebase, but local-first
| Platform | Model | Notes |
|---|---|---|
| InstantDB | Client-side triple store, relational queries (InstaQL), optimistic writes | Open-source, hosted or self-hosted; "a Firebase that syncs." |
| Triplit | Full-stack TypeScript sync engine + DB | Acquired by Supabase in 2025 — expect its ideas to surface there. |
| Jazz | CoValues: CRDT-based collaborative values with built-in auth, permissions, E2EE, sync, and blob storage | The most complete "delete your backend" framework; groups/accounts model handles the auth problem most CRDT stacks punt on. |
| LiveStore | Event-sourcing: append-only eventlog, materialized into in-memory SQLite, synced | From Johannes Schickling (Prisma founder). Redux-meets-SQLite; strong devtools, web + Expo. |
Client-side reactive databases — bring your own sync
| Library | Storage | Notes |
|---|---|---|
| RxDB | IndexedDB/OPFS/SQLite adapters | Mature, plugin-heavy; replication protocols for CouchDB, GraphQL, HTTP, WebRTC. Their why-local-first essay is a good sales pitch for the whole space. |
| TanStack DB | In-memory collections, differential dataflow | Reactive live queries + optimistic mutations over any sync backend (designed to pair with Electric). The "query layer" piece of the puzzle. |
| TinyBase | In-memory, pluggable persisters | Tiny, dependency-free reactive store with CRDT sync and rich devtools. |
| Dexie.js | IndexedDB | The veteran IndexedDB wrapper; Dexie Cloud adds sync. |
| Evolu | SQLite (WASM) | E2E-encrypted local-first SQLite with sync; type-safe, minimalist, privacy-absolutist. |
| Fireproof | Browser-native, content-addressed | Merkle-CRDT ledger DB; sync to any object store. |
| Verdant | IndexedDB | Storage + sync + realtime for small apps; built by one dev for his own products, pragmatic and well-documented. |
CRDT libraries — the raw material
| Library | Sweet spot | Notes |
|---|---|---|
| Yjs | Collaborative text/editors | The industry default; bindings for ProseMirror, CodeMirror, Monaco, tiptap. Servers: Hocuspocus, Y-Sweet, or hosted Liveblocks. |
| Automerge | JSON documents, versioned data | Ink & Switch's library; Rust core, v3 brought ~10× memory reduction. Best-in-class history/branching story. |
| Loro | JSON + rich text + movable trees | Newest generation; Rust, fast, time-travel built in. |
| json-joy | JSON + Peritext rich text | Performance-obsessed; powers mu-txt. |
Two cross-cutting comparisons are worth reading whole: The Spectrum of Local-First Libraries (Triplit vs Evolu vs Zero vs LiveStore, by DX) and Choosing a Sync Engine in 2026 (first-hand selection war story). Neon's framework comparison and Jared Forsyth's database series add rigor. An emerging thread to watch: Ossa, a 2025 attempt at an open universal sync protocol so apps aren't locked to one engine, and Braid, the IETF draft that would make HTTP itself a sync protocol.
05The local storage layer
Whatever engine you pick sits on one of these substrates:
- SQLite-in-WASM (web) — the big unlock of the last few years, via OPFS for persistence. Notion's WASM-SQLite writeup is the canonical case study (including the SharedWorker trick for multi-tab). SQLite-in-Vue guide for a hands-on start.
- Postgres-in-WASM — PGlite (~3 MB gzipped) gives you real Postgres with live queries in the browser. Pairs naturally with Electric.
- CRDT-native SQLite — cr-sqlite is a SQLite extension making tables multi-writer CRDTs ("Git for your data").
- Replicated SQLite as a service — libSQL/Turso embedded replicas: local reads, synced to edge-hosted SQLite.
- IndexedDB — still the compatibility floor; Dexie, RxDB, Verdant, and Fireproof build on it.
- Mobile-embedded — SQLite everywhere (PowerSync, WatermelonDB, op-sqlite/expo-sqlite); Couchbase Lite and ObjectBox for the enterprise/IoT end.
- Plain files — don't laugh: Obsidian and Logseq built empires on Markdown folders, and Tonsky's Local, First, Forever argues file-sync (Dropbox-style) plus a merge-friendly format is the most durable sync substrate of all.
06P2P & the networking layer
Most apps ship with a sync server, but the fully-decentralized end of the spectrum matured too:
- Iroh — Rust, QUIC-based dial-by-public-key connectivity with relay fallback; the current default answer for "I need P2P connections that actually connect."
- Hypercore/Hyperswarm and the Pear runtime — append-only logs, DHT discovery, zero-infrastructure desktop/mobile apps (the Keet stack).
- libp2p / IPFS / OrbitDB — the modular heavyweight stack.
- AT Protocol — self-authenticating data + portable identity; Jake Lazaroff argues it makes a solid sync-server foundation where users own the infrastructure. Nostr is the minimalist relay-based cousin.
- Earthstar — small-group, offline-first P2P sync designed for humans rather than protocols.
Also file under "infrastructure that made this easier": Cloudflare Durable Objects (a stateful, SQLite-backed object per document — the natural home for a sync/presence server) and PartyKit built on top of them.
07The hard parts
These are the areas where you'll spend your actual engineering budget, and where research is still active:
Auth & permissions without a trusted server
The unsolved-until-recently problem. If any device can write, who enforces "read-only collaborator"? Directions: UCAN (capability tokens built on DIDs — delegation works offline), and Ink & Switch's Beehive (convergent capabilities: access control that merges like a CRDT). Jazz ships a practical groups-based version today; most sync engines instead keep permissions on the server (Zero's queries, Electric's shapes, PowerSync's sync rules).
End-to-end encryption
CRDTs merge on any replica, so they can merge on devices — the server can be a blind relay. Excalidraw's E2EE writeup is the approachable intro; Kerkour's CRDT+E2EE research notes covers the sharp edges; for group key management the standard is now MLS (RFC 9420) with OpenMLS as the Rust implementation. Shipping examples: Jazz, Evolu, Anytype, Standard Notes, Notesnook.
Schema migration
Old clients holding old data must interoperate with new ones — you can't run a Rails migration on someone's phone. Ink & Switch's Project Cambria (bidirectional lenses) is the research answer; in practice engines handle it with additive-only schemas, version-gated sync, or per-document versioning. The CTO's guide covers pragmatic strategies. Ask about this before adopting any engine.
Partial sync at scale
You can't replicate a 10 GB workspace to a phone. Every serious engine has a partiality mechanism — Electric's shapes, Zero's synced queries, PowerSync's sync rules — and Linear's scaling story is the best account of what happens when you get this wrong and have to re-architect live.
Version control for everything
The research frontier: Upwelling (private drafts + real-time collab) and Patchwork (branch/diff/merge for arbitrary apps) point at Git-like workflows over CRDT history — Automerge and Loro already expose the primitives.
08Who runs this in production
09Choosing a stack in 2026
The decision tree, distilled from the comparison posts and case studies above. Start from your constraint, not from a library:
| Your situation | Reach for | Why |
|---|---|---|
| Existing Postgres backend, want local-first reads | ElectricSQL + PGlite/TanStack DB | Read-path sync without surrendering your write path or API. |
| Web app, relational data, real-time multiplayer | Zero, or InstantDB | Query-driven sync + server-reconciled writes; permissions stay server-side. |
| Mobile app (RN/Flutter/native), existing backend | PowerSync | Local SQLite, mature mobile SDKs, backend-agnostic writes. (See also Expo's local-first guide.) |
| Mobile, no backend to keep | WatermelonDB, LiveStore (Expo), or Couchbase Lite | On-device-first reactive DBs with sync options. |
| Greenfield, want zero backend code | Jazz, or Triplit-style platform | Auth + permissions + E2EE + sync in one model; accept framework lock-in. |
| Collaborative text/canvas editor | Yjs (+ Hocuspocus/Y-Sweet/Liveblocks), or Loro/Automerge | This is the one domain where CRDTs are unambiguously the answer. |
| Privacy-absolutist / E2EE required | Evolu, Jazz, or Automerge + your own blind relay | Server can't arbitrate what it can't read — you need mergeable data. |
| No servers, ever | Automerge/Loro over Iroh, or Pear runtime | The full-decentralization stack, now actually practical. |
| Event-sourced state, auditability, devtools | LiveStore | Eventlog as source of truth, SQLite as a derived view. |
And the meta-advice, which nearly every practitioner post converges on:
- Decide who arbitrates. Server-reconciled or CRDT-merged is the fork in the road; everything else follows. Default to a server if you have one (you might not need a CRDT).
- Model mutations as intents, not state patches — it's what makes optimistic UI, undo, and server validation composable. (Zero's custom mutators, LiveStore events, Actual's messages all encode this.)
- Plan partial sync and migrations on day one. These are the two things you cannot bolt on later — Linear re-architected twice.
- Steal fractional indexing for anything ordered.
- Prototype the sync failure modes early — two devices offline for a week, clock skew, a tombstoned entity edited elsewhere. Superhuman's unreliable-networks post is the checklist.
10Staying current
The field moves monthly. The high-signal feeds:
- localfirstweb.dev — community hub, with monthly online meetups
- Local-First News — weekly newsletter (releases, talks, meetups)
- localfirst.fm — the podcast; the Maggie Appleton episode on home-cooked software pairs well with the AI moment
- Local-First Discord — where the library authors hang out
- Local-First Conf and SyncConf — the conference circuit
- crdt.tech and awesome-crdt — the theory rabbit hole
If you read only five things from this post: the original essay, You Might Not Need a CRDT, Linear's scaling story, The Spectrum of Local-First Libraries, and Choosing a Sync Engine in 2026. That's the theory, the counterweight, the production reality, and the shopping guide — enough to make a defensible architecture call.
Single-User, Multi-Device, Relay-Only
Applying everything above to a concrete brief: one user, mobile + desktop, P2P sync, a server that introduces devices but never carries their data, and event-sourced leanings. This is the design exercise, option by option.
11Worked example: the brief
The constraints, stated precisely — because each one prunes the decision tree from §9:
- Minimal server cost. Small projects; ideally one cheap VPS or a free-tier worker, nothing that scales with data volume.
- Mobile + desktop, one user, syncing with each other. Web tech allowed but not required.
- Optional third sync-point purely for backup — allowed to hold data, by definition.
- Event-sourced preferred (soft constraint).
- Auth = device certificates + QR-code pairing. No accounts, no passwords, no OAuth.
- Self-hosted relay that only introduces devices (WebRTC-signaling-style) — data flows peer-to-peer, not through the server.
- Offline LAN sync — two devices on the same Wi-Fi should sync with no internet at all.
What this eliminates immediately
| Family | Examples | Why it's out |
|---|---|---|
| Server-reconciled sync engines | Zero, Electric, PowerSync | All require an always-on authoritative database every byte flows through — the exact cost and topology being avoided. |
| Hosted platforms | InstantDB, Triplit, Liveblocks, PartyKit | Cloud is the product. Fine products; wrong topology. |
| LiveStore, despite the event-sourced appeal | LiveStore | Its sync backend stores the canonical eventlog — a data-through server, just a cheap one. Steal its architecture (see §15), skip its sync. |
| Jazz, narrowly | Jazz | Closest miss. Passphrase/QR-ish pairing, E2EE, self-hostable sync — but the sync server is a storing peer, not a connector. The one to revisit if the relay-only constraint ever softens, since the server only ever sees ciphertext. |
Single-user is also a huge simplification, worth cashing in deliberately: no permission system, no presence, no interleaving anxiety — the entire auth hard-part collapses to a device allowlist: "these N public keys are mine." What remains is a pure replication problem between trusted replicas.
The shape every good answer has
direct, E2E-encrypted (QUIC or WebRTC data channel)
LAN via mDNS when offline · hole-punched over the internet
┌──────────────┐ ◀═══════════════════════════════════▶ ┌──────────────┐
│ DESKTOP │ │ PHONE │
│ full replica │ │ full replica │
│ (op-log + │ │ (op-log + │
│ read model) │ │ read model) │
└──────┬───────┘ └──────┬───────┘
│ ┌───────────────────┐ │
└─────────────▶│ RELAY (VPS) │◀──────────────────┘
│ introductions + │
│ hole-punch assist │ never sees plaintext
└───────────────────┘
┌───────────────────────────────────────────────────────────────────┐
│ BACKUP PEER (optional) — a headless third replica: same sync │
│ code, no UI. Or: periodic encrypted snapshots to object storage. │
└───────────────────────────────────────────────────────────────────┘
Four invariants, whatever the stack: (1) every device holds the full op-log and can operate indefinitely alone; (2) identity is a per-device keypair, and pairing means exchanging public keys via QR; (3) the relay's job ends once two peers hold a connection; (4) the backup is not special — it's just a replica that never writes.
12Option A — Rust core: Iroh + CRDT op-log + Tauri
The stack that treats the certs/QR/LAN wishes as native features rather than bolt-ons.
| Layer | Choice | Role |
|---|---|---|
| Shell | Tauri 2 | One Rust core + webview UI, shipping to macOS/Windows/Linux and iOS/Android from one codebase. |
| Transport | Iroh | Dial-by-public-key QUIC connections, hole-punching, relay fallback, local discovery. |
| Data | Automerge (or Loro) | The document/op-log: source of truth, history, merges. |
| Read model | SQLite (bundled with Tauri) | Materialized projection of the doc for fast queries; rebuildable at any time. |
| Relay | Self-hosted iroh-relay | One binary on a ~$4 VPS. Also fine: n0's public relays while prototyping. |
| Backup | Headless iroh + automerge peer, or encrypted snapshots to R2/S3 | Third replica; can share the relay's VPS. |
How each constraint maps
- Certs — an Iroh node is an Ed25519 keypair; its NodeId is the public key, and every connection is mutually-authenticated, end-to-end-encrypted QUIC. Your certificate requirement falls out of the transport for free — there is no separate auth system to build.
- QR pairing — Iroh tickets serialize a NodeId + current addresses into a compact string designed to be shared out-of-band. Render it as a QR on the desktop; scan on the phone; the phone dials, both sides add each other to the allowlist. Pairing is ~50 lines of app code.
- LAN offline — Iroh's local discovery (mDNS-style) finds peers on the same network with zero internet and zero relay. This is the constraint that's genuinely hard everywhere else and free here.
- Relay-only server —
iroh-relayexists precisely to introduce and hole-punch. Stateless, tiny, no data at rest. - Event-sourced — Automerge and Loro are op-logs internally: every change is an operation with causal ordering; you get history, time-travel, and replay (Upwelling/Patchwork-style workflows become possible) without building event sourcing yourself.
The sync loop, concretely
- On connect (LAN discovery, ticket dial, or relay introduction), peers run the Automerge sync protocol over an Iroh stream: exchange heads, send only missing ops. n0 maintains an iroh + automerge example showing exactly this glue.
- While connected, local changes stream to the peer as they happen (per-change messages over the same stream, or
iroh-gossipif you ever exceed two-ish devices). - Every applied change updates the SQLite projection, and the UI reads only from SQLite — the reactive-relational pattern.
Automerge or Loro?
Automerge: the battle-tested ecosystem — Ink & Switch pedigree, a specified sync protocol, automerge-repo patterns to copy, v3's ~10× memory improvements, and the richest history/branching story. Loro: newer and generally faster, with movable trees and built-in time-travel, Rust-first with Swift bindings. Default to Automerge for its documentation and sync protocol maturity; benchmark Loro if your documents get large or tree-shaped. Both are swappable behind a thin trait if you keep the op-log abstraction clean.
- Certs, QR pairing, and LAN sync are native, not bolted on
- One Rust core shared by all platforms; UI still web tech inside Tauri
- Op-log semantics satisfy the event-sourced itch with history included
- Relay is purpose-built, trivial to run, ~free
- Rust learning curve if it's not already home turf
- Tauri mobile is young — expect rough edges in plugins/CI (a native SwiftUI shell is a viable fallback: see the Xcode-free shipping sidebar)
- You own the sync glue (~hundreds of lines, but yours to maintain)
- Mobile OSes kill background sockets — sync is foreground/opportunistic (true for every option)
13Option B — TypeScript everywhere: automerge-repo or Yjs + WebRTC
One TS codebase; the web app is the product, wrapped for each platform.
| Layer | Choice | Role |
|---|---|---|
| App | Web app (any framework) | Wrapped by Tauri on desktop, Capacitor or Expo on mobile. A pure PWA almost works — see the LAN catch. |
| Data | automerge-repo (or Yjs) | Documents + pluggable storage and network adapters. |
| Persistence | IndexedDB adapter (automerge-repo-storage-indexeddb / y-indexeddb) | Local replica. Request persistent storage (navigator.storage.persist()) or browsers may evict it. |
| Transport | WebRTC data channels (y-webrtc for Yjs; community WebRTC adapter for automerge-repo) | DTLS-encrypted, peer-to-peer. |
| Signaling | Tiny WebSocket server or free-tier Cloudflare Worker/Durable Object | Genuinely connector-only — y-webrtc's signaling server is ~200 lines and even encrypts signaling payloads with a room password. |
| Backup | automerge-repo-sync-server (filesystem/S3 storage) or a headless Y-Sweet | Ready-made third replica — this piece is off-the-shelf here, unlike Option A. |
Constraint mapping — where it bends
- QR pairing — works, but the semantics shift: the QR typically carries a room ID + shared secret rather than device certificates. Fine for one user; just be aware identity is "knows the secret," not "is this hardware key."
- Certs — the honest minus. WebRTC's DTLS certificates are per-session plumbing, not stable device identities. You can layer libsodium keypairs on top (sign a challenge during pairing), but now you're building the identity layer Iroh gives you free.
- LAN offline — the catch. Browsers cannot mDNS, so a pure PWA can't discover a peer on the LAN without internet. Two workarounds: (a) native wrappers do discovery via a bonjour/mDNS plugin and hand sockets to JS; (b) the neat trick — the desktop app hosts the signaling server itself, and the phone reaches it via a QR code containing the desktop's LAN address. Both work; both mean you've taken on native shells anyway, which weakens the main argument for choosing web tech to begin with.
- Event-sourced — same as Option A: Automerge/Yjs docs are op-logs under the hood. Yjs exposes less history ergonomics than Automerge; if replay/time-travel matters, prefer automerge-repo here.
- Fastest path to a working MVP; one language everywhere
- Backup peer is literally an npm package you run
- Biggest ecosystem (editor bindings, examples, community)
- Free-tier signaling: a Durable Object or 200-line WS server
- Device-cert identity must be hand-rolled on top
- LAN-offline requires wrapper tricks or desktop-hosted signaling
- IndexedDB eviction risk on iOS Safari if unwrapped
- WebRTC NAT traversal is famously moody; budget debugging time
14Wildcards & the DIY corner
Pear / Hypercore — the zero-server maximalist
On paper the most literal match in the whole list: Hypercore is an append-only signed log (event sourcing as a primitive, not a pattern), Hyperswarm discovers peers over a DHT so there is no server at all — not even signaling — and the Pear runtime ships desktop and mobile apps with P2P built in. Why it's not the default: you're locked into the Pear runtime and its JS dialect; mobile support is young; and Hypercores are single-writer, so multi-device writes need Autobase (log-linearization) gymnastics that reintroduce the complexity you avoided. Verdict: a genuinely tempting weekend spike if "no server, ever" calls to you; a risky foundation for something you want to still build on in three years.
The DIY event log — CRDTs for mortals, your way
If the event-sourced preference is really a desire to own the core: copy Actual Budget's architecture, walked through in CRDTs for Mortals with a full reference implementation. Every write is a message (HLC timestamp, entity, field, value); SQLite stores both the message log and the materialized state; merging is per-field last-writer-wins by hybrid logical clock; syncing is "exchange messages you haven't seen" — over literally any pipe, including an Iroh stream or WebRTC channel from Options A/B. It's a few hundred lines, fully comprehensible, trivially debuggable, and perfectly suited to single-user structured data. What you give up: rich-text merging, tree moves, and every hard case the CRDT libraries spent five years on (the hard parts). Fine trade for a finance/notes/tracker app; bad trade for an editor.
Tonsky's file-sync heresy
For completeness, Local, First, Forever: store state as files in a merge-friendly format and let Syncthing/Dropbox move them. Maximum longevity, zero infrastructure — and how Obsidian conquered the world. Weak fit here only because mobile file-sync daemons are second-class citizens; keep it in mind as the durability benchmark your export format should meet.
15The pick & day-one decisions
iroh-relay binary plus an optional headless backup peer on the same ~$4 VPS. Choose Option B only if fast UI iteration in one TS codebase outweighs owning the identity layer — and accept the wrapper requirement for LAN sync either way.Note what this architecture is: LiveStore's design — eventlog as source of truth, SQLite as a derived view — with the server removed. Doc → projection → UI; the CRDT layer replaces the sync backend.
Decisions to make on day one (not day ninety)
- Additive-only schema. An un-updated phone must merge with an updated desktop. New fields get defaults; never repurpose or delete. When you truly must break, version the document and migrate-on-read (Cambria is the research north star; a
schema_versionfield and per-version readers are the practical one). - Compaction. Op-logs grow forever. Automerge and Loro both have snapshot/compaction mechanisms — exercise them before the log is 100 MB, not after, and decide how much history you actually keep.
- Device lifecycle. Pairing is easy; revocation is the part people skip. A lost phone holds a valid key — plan a "remove device" flow (drop from allowlist everywhere, rotate any shared secrets) and an ordinal: which device can revoke which?
- Never trust wall clocks. Order by the op-log's causal order (or HLCs if DIY). Phone clocks drift and users fly across time zones; wall-clock LWW eats writes silently.
- Mobile background reality. iOS/Android kill background sockets. Design for opportunistic sync — on foreground, on network change, maybe a push-nudge later — and make the UI honest about "last synced."
- Rehearse the restore. A backup that's never been restored is a rumor. Script "new empty device + backup peer → full state" and run it before shipping. (Superhuman's checklist for the failure modes to simulate: week-offline devices, clock skew, edits to tombstoned entities.)
- Export early. Longevity ideal #5: a one-tap plain-format export (JSON/Markdown/SQLite file). It's also your escape hatch from every library named on this page.
16A build roadmap
Weekend-sized increments, each independently verifiable:
- Transport spike. Two CLI Rust binaries on different networks: Iroh ticket printed by one, pasted into the other, Automerge doc syncing between them (start from n0's iroh + automerge example). No UI. Proves the entire hard layer.
- Desktop shell. Tauri 2 app: Automerge doc + SQLite projection + a trivial UI writing through the op-log. Kill the app mid-write; confirm clean recovery.
- Pairing UX. QR ticket rendering + scan flow + persistent device allowlist. Then pull the network cable and confirm LAN discovery syncs two machines on Wi-Fi alone.
- Relay.
iroh-relayon the VPS; devices on different networks (phone on cellular) sync through introductions. Log whether connections are direct or relay-fallback so you know your real hole-punch rate. - Backup peer. Headless binary on the same VPS subscribing to everything. Run the restore drill from §15.6.
- Mobile build. Tauri mobile target (or a thin native shell over the same Rust core via UniFFI if Tauri mobile fights you). Foreground-sync triggers; airplane-mode-with-Wi-Fi LAN test.
- Compaction + export. Snapshot strategy under a month of simulated writes; one-tap export. Now build the actual app.
Steps 1–4 are the whole risk. If the transport spike works in an afternoon, everything after it is product work.
Shipping sidebar: Mac + iOS without ever opening Xcode
Whichever shell you pick in step 6 — Tauri mobile, or a native SwiftUI shell over the Rust core — you eventually hit Apple's build/sign/notarize wall, and the fear of GUI-Xcode drudgery is a real reason people avoid native shells. Scott Willsey's Building and Shipping Mac and iOS Apps Without Ever Opening Xcode demolishes that fear: the entire pipeline is scriptable, and the memorable insight is that Xcode.app must be installed, but it never has to be open.
The toolchain, in pipeline order:
- XcodeGen — the project is a committed
project.yml; the.xcodeprojis regenerated on every build and never versioned. Team ID and bundle prefix live in a gitignoredLocal.xcconfig. xcodebuild— archive and export (Developer ID signing viaExportOptions.plist); fast unsigned dev builds withCODE_SIGNING_ALLOWED=NO.xcrun notarytool submit --wait→xcrun stapler staple— Apple's malware scan and ticket attachment, credentials stored once in the keychain (notarytool store-credentials), never in shell history or env vars.xcrun devicectl device install app— push the iOS build straight onto a physical iPhone from the terminal.- One
scripts/release.shruns the whole chain; aCLAUDE.mddocuments the conventions so an AI agent can drive the releases — the author's actual recommendation is to have Claude Code write the pipeline rather than hand-crafting it.
The gotchas he hit are the ones that will otherwise cost you an afternoon each: the standalone Command Line Tools lack the iOS SDK and notarytool (full Xcode install required); a Developer ID Application certificate is not an Apple Development certificate, and only the former ships; signing private keys can never be re-downloaded, so a deleted keychain entry is permanent loss; app-specific passwords silently die when the main Apple ID password changes (surfacing as bare 401s); and — most relevant to this page — ad-hoc-signed builds don't bind entitlements, so iCloud/App Group–dependent sync silently returns empty data, looking exactly like a bug in your sync layer when it's actually a signing problem. Pin that one to the wall before debugging step 6.
It also quietly strengthens the all-Apple wildcard: if your devices are a Mac and an iPhone, a native SwiftUI shell over the shared Rust core (via UniFFI) loses its usual tooling-dread penalty when the whole release is ./scripts/release.sh — worth weighing against Tauri mobile if you hit its rough edges.
17Extension: a vendor server node & website
Now relax the brief's proudest constraint. Suppose you — the vendor — run an always-on server that is a full sync peer (it holds the op-log, not just introductions), you control it completely, and you can allowlist exactly who may connect. In exchange you want the thing the pure-P2P design can't give you: a website — open a browser on any machine, see your data. This is precisely the walk back from column 3–4 of the spectrum to the pragmatic middle of §1, done with open eyes.
direct, E2E-encrypted P2P — unchanged from §11
┌──────────────┐ ◀═══════════════════════════════════▶ ┌──────────────┐
│ DESKTOP │ │ PHONE │
└──────┬───────┘ └──────┬───────┘
│ │
└───────────────┐ ┌────────────────┘
▼ ▼
┌─────────────────────────────────────┐
│ VENDOR SERVER (VPS) │
│ full replica · always on · yours │
│ relay + backup peer, promoted │
│ WebSocket sync bridge + web app │──── sees plaintext
└──────────────────┬──────────────────┘ (see tradeoffs)
│ HTTPS + WebSocket
▼
┌──────────────────┐
│ BROWSER │
│IndexedDB replica │
│ session auth │
└──────────────────┘
How to integrate it
- Promote the backup peer. The headless replica from step 5 already runs the same sync code as every other device; give it a stable address and keep it on the allowlist. To the native mesh, nothing changed — it's one more NodeId, just one that's always reachable. It absorbs the relay and backup roles it already had.
- Add a browser bridge. Browsers can't speak Iroh's QUIC or do LAN discovery, so the server exposes the op-log over plain WebSockets — the automerge-repo-sync-server pattern, backed by the same document store the Iroh side syncs. The browser client is the Option B stack pointed at one server: automerge-repo + IndexedDB storage adapter + WebSocket network adapter, with
navigator.storage.persist()requested. - Serve the web app from the same box. Static bundle + WS endpoint + replica: one binary or two, one ~$4 VPS still.
- Leave the natives alone. Desktop and phone keep dialing each other directly (LAN, tickets, hole-punching). The server is a rendezvous replica, not a hub — sync no longer needs both your devices awake at once, which quietly fixes the worst UX wart of the two-peer design.
What you gain, besides the website: an always-on peer (no more "did they overlap online?"), a backup that is structurally identical to a device, one place to run server-side niceties over the replica (full-text search, scheduled exports, webhooks, push-nudges), and a single choke point where you decide who gets in.
The tradeoffs — what stops being possible
| Lost | Why |
|---|---|
| "Server never sees plaintext" | The web node syncs and materializes documents to serve queries and render UI. The §11 invariant dies here — not by accident but by job description. (The E2EE-preserving alternative is analyzed below; it costs you the website.) |
| Zero data-at-rest liability | You now hold user data on infrastructure you own: breach surface, backups of the backup, jurisdiction and GDPR-style obligations. The relay-only design's best property was that there was nothing to steal or subpoena; that's gone. |
| Cost that doesn't scale with data | The relay cost ~nothing per byte because it carried introductions. A replica-holding, browser-serving node grows with documents, history, and web users. |
| Full offline parity for the web client | The browser replica works offline for what it has, but: no LAN sync (still no mDNS), eviction risk on iOS Safari, and while your server is down the browser can't sync with anyone — the natives can. The website is the one client whose availability is coupled to yours. |
| Topology honesty | You can no longer describe the system as relay-only P2P. You're now the pragmatic middle of §1: local replicas everywhere, vendor server authoritative for access. Most commercial "local-first" apps live here; the point of doing it deliberately is knowing which ideals you traded (longevity and user control now partly depend on you staying in business — keep the §15 export hatch). |
The auth problems, in order of pain
- You now have two identity planes. The native mesh authenticates with device keypairs (QR-paired, transport-level). Browsers can't hold an Iroh identity, so the web path needs accounts and sessions — and the clean bridge is passkeys/WebAuthn, which are themselves device-bound keypairs, phishing-resistant and password-free. The server keeps the mapping: account → allowed device NodeIds + active web sessions. The allowlist stops being a fact each peer holds and becomes a table you own.
- Admission control is not write control. Your allowlist decides who may connect and sync; it cannot make an admitted CRDT writer partial. Any device you admit is a total writer — there is no "can only edit their own entries" without either validating server-side (rejecting ops at the bridge, i.e. server reconciliation à la §3 — natural for the web path) or capability systems like UCAN/Beehive on the P2P side. "Read-only web viewer" is enforceable — the bridge just never accepts ops from that session — but only because that traffic passes through you.
- P2P routes around your revocation. Kicking a device off the server's allowlist stops it syncing through you — but your desktop and phone accept direct connections based on their local allowlists. A revoked (lost, stolen) device can still dial a peer on the LAN. Fix: make the allowlist itself a synced, server-signed document that every peer enforces on connect, or use short-lived delegations that must be refreshed through the server. Either way, revocation must propagate to peers, not just to the hub — this is the §15 "device lifecycle" decision with real teeth now.
- Revocation still isn't recall. Whatever a browser session or device replicated before you cut it off, it keeps. A web session on a borrowed laptop leaves an IndexedDB replica behind — so logout must wipe local storage, which is exactly the opposite of the local-first reflex. Decide per-client: native devices persist by default; browsers should treat the replica as a cache tied to the session.
- E2EE-with-a-website is mostly theater. Could you keep the server blind (store ciphertext, sync encrypted ops) and still serve a web app? Technically — keys delivered by QR from the phone or a passphrase — but the server ships the JavaScript, so a malicious-or-compelled you could always serve code that exfiltrates keys. E2EE against the vendor is incoherent when the vendor delivers the client on every page load. Native apps don't have this problem (the binary is installed once, verifiable); browsers structurally do.