“Local-first” has become nearly synonymous with CRDTs, and the conflation is expensive: it pushes single-user apps toward merge machinery they do not need and obscures the cases where the machinery is exactly right. The question worth answering is narrower: what does a local-first app actually require, and which of those requirements do CRDTs satisfy?
The requirements are: the device holds authority over its own data, history is ordered enough to detect staleness, a lost response is recoverable without duplication, and concurrent writes resolve by a rule everyone can state. For a single-user or single-writer app, all four are satisfied by an ordered log plus content addressing and compare-and-swap, the architecture Soundfish, PeopleBlade, Wordcell, and Oh each implement in different forms. CRDTs enter when there is no single writer to serialize the log.
what single-writer sync actually needs
A single-user app has one writer per logical object at a time: you edit your loop on this tab, on this device, now. The hard problems are not merge problems. They are: a second tab or device editing the same object while the first was offline; a save whose response was lost; a delete racing an import; a restored tab holding a head the world has moved past.
None of those require automatic merging. They require that a stale write be detected and resolved by a stated rule, which is exactly what compare-and-swap plus forking gives you. The complexity budget goes to fencing and recovery, not to merge algebra.
Two cheap primitives do most of the work. Content addressing makes retries safe: when identity is the digest of the canonical bytes, replaying a write is idempotent by definition. PeopleBlade’s importers return the stored receipt when the same source artifact replays, and soulscrape republishes identical packet bytes as a no-op. And an expected-head precondition makes staleness decidable: the writer names the state it saw, so “did the world move” is a comparison, not a heuristic.
compare-and-swap is the merge protocol
Soundfish’s cloud sync is the cleanest example of how far CAS takes you. A checkpoint names the head digest the caller observed plus up to MAX_SYNC_PARENTS (two) parent digests, and the schema requires the expected head to be retained among the parents. The server applies the checkpoint only if the observed head is current; otherwise it returns conflict with the current head: a stale head branches or conflicts instead of overwriting. Locally the same rule holds for cross-tab edits: a stale edit forks under a new logical ID rather than overwriting, and a stale delete preserves the newer head.
The identity fencing is the subtle part. A claimGeneration (an account-wide monotonic counter) separates “I lost the response to a create” from “someone deleted and recreated”: a lost create may advance the claim only through an atomic absent-only fence, an occupied ID preserves the concurrent head, and a new head is stamped with its creation generation so an older claim cannot bind across delete-and-recreate. Deletes write a durable IndexedDB fence before local removal. The result is a sync model with no merge function at all: concurrent writers do not merge, they fork, and the fork is explicit, named, and recoverable.
the log is the history
CAS needs an ordered history to compare against, and the Hraness pattern is to make that history append-only and verifiable rather than a derived cache.
Oh is the formal version: oh_operations is an append-only canonical operation chain, oh_spaces holds the current compare-and-swap head, and oh_operation_records materializes each operation’s record changes. oh verify replays the chain from an empty graph, recomputes the record-set and revision digests, and requires the reconstructed head to equal the stored head; the log is authoritative and the materialization is a checked cache of it. Sync state lives in its own tables (oh_sync_outbox, oh_sync_state) beside the authority, never inside it, and a divergent sync history is explicit conflict handling rather than last-write-wins.
PeopleBlade shows the asymmetric variant: the local SQLite file is authoritative (canonical people, provider-owned raw identities, immutable source runs, append-only identity decisions), and peopleblade cloud sync computes a bounded projection of it, sending no source-record payloads, message bodies, raw archives, local paths, or provider credentials. The cloud is a publication target, not a peer. An accepted identity decision changes only the derived component while provider foreign keys stay for audit, so even the merge-like operation (two raw identities judged to be one person) is an append to the decision log, not a rewrite of source rows.
Wordcell’s version is the plainest: Markdown files are the state and Git is the ordered log. Capture history is Git history (wordcell capture diff compares the current bundle’s Markdown with a bounded ref), and the tool maintains no second content-history database. Even its disposable graph projection is rebuilt like a deployment rather than merged like a replica: wordcell graph rebuild stages changed records in a private database, verifies replay, rechecks the Markdown revision, and installs atomically; a failed build leaves the previous database in place.
What all four share is the absence of a merge function over user content. Writes are serialized by a precondition (an expected head, an expected revision, an expected generation), and the cases that look like merges turn out to be evidence-plus-a-rule: the decision is recorded, the conflict forks, the stale claim is refused. The log stays single-writer at each step even when the writers race.
where CRDTs earn their complexity
The case that actually needs merge machinery is live simultaneous multi-writer editing of one object: two people typing into the same document at the same time, where serializing writes would mean locking each other out. That is a real product requirement for collaborative editors, and a CRDT (or an OT server) is the honest answer to it.
Sponge’s document editor makes the alternative visible: instead of merging, it serializes. Every mutation needs a live fenced lease (HUMAN_LEASE_TTL_MS = 4_000 because the editor renews on each keystroke batch, AGENT_LEASE_TTL_MS = 30_000 because one tool call may take seconds), the server stores only the fenceTokenHash, and a stale fence is refused with STALE_FENCE. Leases, not merges, keep the shared head single-writer at any instant. That works because the collaboration model tolerates serialization; a Google Docs–style free-for-all would not.
when local-first is wrong
The honest list of cases where local-first is the wrong architecture entirely:
- Server-authoritative data. Inventory, pricing, payments, shared mutable state where the server must arbitrate: the local copy is a cache by definition.
- Dense simultaneous collaboration. Multi-caret documents, shared whiteboards: you want a CRDT or a central sequencer, not a fork-per-conflict policy.
- Data whose authority is someone else’s. Syncing a provider’s canonical records (contacts owned by Apple, messages owned by a platform) means importing evidence about remote truth, not owning it. PeopleBlade’s importers are deliberately asymmetric for exactly this reason: a provider’s exact stable resource can reuse its own prior mapping, but a phone, email, or display name never transfers ownership across providers: overlap is review evidence, not an automatic merge.
- Regulatory or consent boundaries that require central deletion authority the local model cannot honestly promise.
the decision
| Situation | Model that fits |
|---|---|
| Single user, a few devices, offline-tolerant | Local authority + ordered log + CAS + content addressing |
| One shared object, edits serialize naturally | Fenced leases over a central head (Sponge’s lease model) |
| Live simultaneous multi-writer on one object | CRDT or OT: this is the case that justifies it |
| Server must arbitrate the data | Local copy is a cache; do not call it local-first |
| Foreign provider owns the records | Asymmetric import + derived projection, never ownership transfer |
The synthesis the codebases keep landing on: local-first is a claim about where authority lives, not a claim about which data structure does your merge. An ordered log, digests for identity, compare-and-swap for staleness, and explicit forking for genuine conflict cover the entire single-writer problem, and the parts that look like merge (PeopleBlade’s identity decisions, Soundfish’s ambiguous-create fences) turn out to be append-only evidence plus a stated rule. Reach for a CRDT when two writers must both win inside one object, and not before.
sources
- sound.fish:
lib/library/checkpoint, fence, and fork semantics - oh:
spec/v1/storage.mdoperation log and replay verification - peopleblade:
docs/ARCHITECTURE.mdasymmetric import and bounded projection - wordcell:
docs/capture.mdGit as capture history - sponge: fenced leases (
HUMAN_LEASE_TTL_MS,AGENT_LEASE_TTL_MS,STALE_FENCE) - soulscrape: digest-idempotent publication