Crash Course · 2026 Edition

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.

Sources summarized & cross-referenced from awesome-local-first · Part One: the field (~25 min) · Part Two: a worked stack decision (~15 min)

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:

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:

Offline cacheServer-authoritative. Queue writes, replay on reconnect. Trello, most PWAs.
Synced replicaFull/partial local DB, server reconciles. Linear, Zero, Electric, PowerSync.
CRDT documentAny replica merges with any other; server is a dumb relay. Automerge, Yjs, Jazz.
Pure P2PNo server at all. Hypercore/Pear, Iroh, Earthstar, Anytype.

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:

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.

The trick worth stealingEven non-CRDT systems use fractional indexing for ordered lists — give each item a position key between its neighbors so concurrent inserts don't collide. Liveblocks has the best walkthrough; Figma described the same trick in their multiplayer post.

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

EngineModelNotes
ElectricSQLPartial replication of Postgres via "shapes" over HTTPRead-path sync engine; writes go through your API. Pairs with PGlite (Postgres-in-WASM) or TanStack DB on the client.
PowerSyncPostgres/MongoDB/MySQL → local SQLite, dynamic sync rulesStrongest mobile story (Flutter, React Native, Kotlin, Swift). Writes via upload queue to your backend.
ZeroQuery-driven sync: client subscribes to ZQL queries, server maintains them incrementallyFrom 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

PlatformModelNotes
InstantDBClient-side triple store, relational queries (InstaQL), optimistic writesOpen-source, hosted or self-hosted; "a Firebase that syncs."
TriplitFull-stack TypeScript sync engine + DBAcquired by Supabase in 2025 — expect its ideas to surface there.
JazzCoValues: CRDT-based collaborative values with built-in auth, permissions, E2EE, sync, and blob storageThe most complete "delete your backend" framework; groups/accounts model handles the auth problem most CRDT stacks punt on.
LiveStoreEvent-sourcing: append-only eventlog, materialized into in-memory SQLite, syncedFrom Johannes Schickling (Prisma founder). Redux-meets-SQLite; strong devtools, web + Expo.

Client-side reactive databases — bring your own sync

LibraryStorageNotes
RxDBIndexedDB/OPFS/SQLite adaptersMature, plugin-heavy; replication protocols for CouchDB, GraphQL, HTTP, WebRTC. Their why-local-first essay is a good sales pitch for the whole space.
TanStack DBIn-memory collections, differential dataflowReactive live queries + optimistic mutations over any sync backend (designed to pair with Electric). The "query layer" piece of the puzzle.
TinyBaseIn-memory, pluggable persistersTiny, dependency-free reactive store with CRDT sync and rich devtools.
Dexie.jsIndexedDBThe veteran IndexedDB wrapper; Dexie Cloud adds sync.
EvoluSQLite (WASM)E2E-encrypted local-first SQLite with sync; type-safe, minimalist, privacy-absolutist.
FireproofBrowser-native, content-addressedMerkle-CRDT ledger DB; sync to any object store.
VerdantIndexedDBStorage + sync + realtime for small apps; built by one dev for his own products, pragmatic and well-documented.

CRDT libraries — the raw material

LibrarySweet spotNotes
YjsCollaborative text/editorsThe industry default; bindings for ProseMirror, CodeMirror, Monaco, tiptap. Servers: Hocuspocus, Y-Sweet, or hosted Liveblocks.
AutomergeJSON documents, versioned dataInk & Switch's library; Rust core, v3 brought ~10× memory reduction. Best-in-class history/branching story.
LoroJSON + rich text + movable treesNewest generation; Rust, fast, time-travel built in.
json-joyJSON + Peritext rich textPerformance-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:

06P2P & the networking layer

Most apps ship with a sync server, but the fully-decentralized end of the spectrum matured too:

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

Linear — the synced-replica flagshipFull client-side replica (IndexedDB), server-sequenced deltas, lazy partial sync as they scaled. Their writeup is required reading, and their model directly inspired Zero.
Figma — server reconciliation, no CRDTsPer-property LWW arbitrated by a document server, fractional indexing for order, plus a WAL for reliability. Proof that "multiplayer" ≠ "CRDT."
Superhuman — offline-first disciplineLocal store + queued commands, engineered around the 100 ms rule and unreliable networks.
Notion — local SQLite for speed onlyWASM SQLite cache cut navigation times ~20% — local-first techniques adopted piecemeal by a cloud app.
Actual Budget — CRDTs for mortals, shippedOpen-source budgeting: SQLite + per-column LWW CRDT messages, syncable through a dumb server. The best small codebase to actually read.
Anytype / AFFiNE / Excalidraw / tldraw — the native generationP2P + E2EE knowledge OS (Anytype), Yjs-backed workspace (AFFiNE), E2EE collaborative canvases (Excalidraw, tldraw).

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 situationReach forWhy
Existing Postgres backend, want local-first readsElectricSQL + PGlite/TanStack DBRead-path sync without surrendering your write path or API.
Web app, relational data, real-time multiplayerZero, or InstantDBQuery-driven sync + server-reconciled writes; permissions stay server-side.
Mobile app (RN/Flutter/native), existing backendPowerSyncLocal SQLite, mature mobile SDKs, backend-agnostic writes. (See also Expo's local-first guide.)
Mobile, no backend to keepWatermelonDB, LiveStore (Expo), or Couchbase LiteOn-device-first reactive DBs with sync options.
Greenfield, want zero backend codeJazz, or Triplit-style platformAuth + permissions + E2EE + sync in one model; accept framework lock-in.
Collaborative text/canvas editorYjs (+ Hocuspocus/Y-Sweet/Liveblocks), or Loro/AutomergeThis is the one domain where CRDTs are unambiguously the answer.
Privacy-absolutist / E2EE requiredEvolu, Jazz, or Automerge + your own blind relayServer can't arbitrate what it can't read — you need mergeable data.
No servers, everAutomerge/Loro over Iroh, or Pear runtimeThe full-decentralization stack, now actually practical.
Event-sourced state, auditability, devtoolsLiveStoreEventlog as source of truth, SQLite as a derived view.

And the meta-advice, which nearly every practitioner post converges on:

  1. 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).
  2. 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.)
  3. Plan partial sync and migrations on day one. These are the two things you cannot bolt on later — Linear re-architected twice.
  4. Steal fractional indexing for anything ordered.
  5. 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:

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.

Part Two · Worked Example

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:

What this eliminates immediately

FamilyExamplesWhy it's out
Server-reconciled sync enginesZero, Electric, PowerSyncAll require an always-on authoritative database every byte flows through — the exact cost and topology being avoided.
Hosted platformsInstantDB, Triplit, Liveblocks, PartyKitCloud is the product. Fine products; wrong topology.
LiveStore, despite the event-sourced appealLiveStoreIts sync backend stores the canonical eventlog — a data-through server, just a cheap one. Steal its architecture (see §15), skip its sync.
Jazz, narrowlyJazzClosest 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.

One honest nuance before comparing options"Never carries traffic" is not fully achievable on the modern internet. When NAT hole-punching fails (symmetric NATs, hostile hotel Wi-Fi), both Iroh's relay and WebRTC's TURN fallback forward encrypted bytes — they can never read them, but they do carry them. Between your own desktop and phone, frequently on the same LAN, you'll be direct nearly always. Decide now that E2EE-fallback-through-relay is acceptable; every serious P2P system makes this trade.

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.

LayerChoiceRole
ShellTauri 2One Rust core + webview UI, shipping to macOS/Windows/Linux and iOS/Android from one codebase.
TransportIrohDial-by-public-key QUIC connections, hole-punching, relay fallback, local discovery.
DataAutomerge (or Loro)The document/op-log: source of truth, history, merges.
Read modelSQLite (bundled with Tauri)Materialized projection of the doc for fast queries; rebuildable at any time.
RelaySelf-hosted iroh-relayOne binary on a ~$4 VPS. Also fine: n0's public relays while prototyping.
BackupHeadless iroh + automerge peer, or encrypted snapshots to R2/S3Third replica; can share the relay's VPS.

How each constraint maps

The sync loop, concretely

  1. 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.
  2. While connected, local changes stream to the peer as they happen (per-change messages over the same stream, or iroh-gossip if you ever exceed two-ish devices).
  3. 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.

For
  • 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
Against
  • 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.

LayerChoiceRole
AppWeb app (any framework)Wrapped by Tauri on desktop, Capacitor or Expo on mobile. A pure PWA almost works — see the LAN catch.
Dataautomerge-repo (or Yjs)Documents + pluggable storage and network adapters.
PersistenceIndexedDB adapter (automerge-repo-storage-indexeddb / y-indexeddb)Local replica. Request persistent storage (navigator.storage.persist()) or browsers may evict it.
TransportWebRTC data channels (y-webrtc for Yjs; community WebRTC adapter for automerge-repo)DTLS-encrypted, peer-to-peer.
SignalingTiny WebSocket server or free-tier Cloudflare Worker/Durable ObjectGenuinely connector-only — y-webrtc's signaling server is ~200 lines and even encrypts signaling payloads with a room password.
Backupautomerge-repo-sync-server (filesystem/S3 storage) or a headless Y-SweetReady-made third replica — this piece is off-the-shelf here, unlike Option A.

Constraint mapping — where it bends

For
  • 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
Against
  • 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

RecommendationOption A: Tauri 2 + Iroh + Automerge (benchmark Loro if documents get big), with SQLite as the materialized read model. Every constraint that's a feature request in this brief — device certs, QR pairing, offline LAN sync, connector-only relay — is a native property of Iroh rather than something bolted onto a browser stack. The op-log satisfies the event-sourced preference with history and time-travel included. Total server footprint: one 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)

  1. 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_version field and per-version readers are the practical one).
  2. 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.
  3. 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?
  4. 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.
  5. 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."
  6. 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.)
  7. 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:

  1. 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.
  2. 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.
  3. 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.
  4. Relay. iroh-relay on 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.
  5. Backup peer. Headless binary on the same VPS subscribing to everything. Run the restore drill from §15.6.
  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.
  7. 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:

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

  1. 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.
  2. 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.
  3. Serve the web app from the same box. Static bundle + WS endpoint + replica: one binary or two, one ~$4 VPS still.
  4. 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

LostWhy
"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 liabilityYou 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 dataThe 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 clientThe 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 honestyYou 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Decide the trust stance, oncePick one and write it down. Trusted-vendor mode: the server holds plaintext, the website works, and auth collapses to boring, excellent web practice — passkeys, sessions, a server-enforced allowlist, ops validated at the bridge. You've re-entered "let the server arbitrate" (you might not need a CRDT) for the web path while the native mesh stays P2P. Blind-vendor mode: the server stores only ciphertext, auth stays capability/allowlist-based — and there is no real website, at most an "unlock with your phone" viewer you must trust yourself not to backdoor. For a single-user system where the vendor is you, trusted-vendor is the honest default; blind-vendor is worth the pain only when the threat model includes your own server.