HomeJournalThis post

Yjs vs Automerge for Local-First Apps

Compare Yjs and Automerge through document boundaries, history, adapters, storage, sync, export, and recovery—without inventing a universal winner.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

Yjs vs Automerge is a product-architecture decision about documents, history, adapters, recovery, and ownership. This comparison maps the same local-first workloads through both ecosystems and requires a prototype whenever primary evidence stays tied or incomplete.

Yjs vs Automerge starts with the product job

Yjs vs Automerge is an architecture choice for a local-first product, not a benchmark trophy. Freeze the workload before naming a library: document boundaries, offline duration, collaboration shape, editor integration, history needs, storage ownership, export requirements, and recovery expectations. Those constraints decide what evidence matters.

This comparison follows the same offline edit journey through both ecosystems. It does not execute either library or announce a universal winner. Instead, it maps source-backed capabilities to three synthetic workloads and returns “prototype both” whenever decisive evidence is absent or tied. That makes the article a selection protocol, not a disguised performance ranking. A local-first CRDT choice begins with required behavior and ends with a prototype receipt that a product team can challenge.

The local-first goal also reaches beyond CRDT merge semantics. The Local-first software essay frames ownership, offline operation, longevity, and collaboration as product qualities. A library can support part of that architecture while the product still owns accounts, encryption, storage durability, permissions, export, presence, and recovery. Start with the job, then use Yjs vs Automerge to identify which proof-of-concept can retire the most important uncertainty.

One offline edit journey, two library stacksA local mutation passes through Yjs document updates or Automerge document history while product-owned storage, network, identity, and presence stay explicit.YJSAUTOMERGElocal mutationbinary updateprovider + storagelocal changedocument historyRepo adaptersproduct owns identity · authorization · presence · recovery
One offline edit journey, two library stacks
A local mutation passes through Yjs document updates or Automerge document history while product-owned storage, network, identity, and presence stay explicit.
Parallel journey
LayerYjs pathAutomerge pathProduct obligation
Replicated stateShared types in Y.DocAutomerge documentChoose boundaries
Change transportDocument updatesSync protocol via adapterAuthenticate and authorize
Local durabilityChosen provider/storeRepo storage adapterBackup and restore
PresenceSeparate awareness pathProduct pathKeep ephemeral
Figure 1: Library layers differ, while the product retains the same governance duties.

Map Yjs shared types and document updates

Yjs exposes collaborative shared types inside a document and distributes binary updates. Its document update documentation states that updates are commutative, associative, and idempotent, and describes state vectors, update merging, and an alternative API that can operate on updates without loading a Y.Doc.

Those properties matter for replicated transport and storage, but the product must still decide how many documents exist and what one update log represents. Large application objects may need boundaries for loading, permissions, backup, and deletion. Editor bindings can make Yjs attractive for collaborative text, yet adapter fit should be prototyped with the actual schema and interaction model.

The existing Yjs offline editor implementation is a concrete implementation reference. This Yjs vs Automerge article owns the earlier choice: whether update-oriented documents, available editor integrations, and product-owned provider layers fit the workload. It deliberately does not reteach that editor. Record which Yjs layer owns shared state and which product layer owns persistence, network connection, presence, and user identity. Also preserve the update-boundary decision: Yjs document updates may be merged or stored without loading the document, but retention, grouping, encryption, and deletion still need an application policy.

Map Automerge documents, history, and Repo

Automerge centers immutable document states backed by a change history. Automerge Repo adds handles and adapters around storage and networking. The official Automerge concepts documentation explains documents, changes, history, DocHandle, repository responsibilities, storage adapters, network adapters, synchronization, and compression.

History is a product capability only when its retention, presentation, and recovery behavior match the interface. Ask whether users need named versions, inspection, undo, export, or audit trails, and which of those are application features rather than automatic consequences of keeping changes. Prototype the actual document size and update pattern before promising cheap history.

For Yjs vs Automerge, Automerge Repo can reduce some integration assembly, but it does not choose a durable backend, trust model, or account boundary for the team. A handle is not a permission system. The decision record should name which adapter is proposed, how it behaves offline, how it reconnects, and what happens when storage is unavailable or corrupt. Pin adapter versions in the prototype and capture the restart path. A convenience layer that works during a connected demo may still leave durability or identity work unowned.

Assign storage, networking, and presence ownership

Draw four boxes: replicated document, local durable storage, remote sync transport, and ephemeral presence. Connect each box to the library layer or product service that owns it. Yjs vs Automerge becomes much clearer when “sync” is split into those responsibilities.

Presence is usually transient and should not silently become document history. Authentication and authorization sit around the transport and storage, not inside merge semantics. Encryption may protect bytes, but key distribution and metadata remain product concerns. Backups need restore drills at the same document granularity used in production.

Same-origin coordination is another separate decision; choose BroadcastChannel or SharedWorker independently rather than using it as evidence for remote collaboration. Likewise, an IndexedDB draft queue can preserve operations without providing a replicated document model. Naming these layers prevents a library comparison from absorbing every offline feature into one vague checkbox. For offline collaboration, write a failure owner beside each arrow: client, adapter, relay, database, key service, or operator. Then the prototype can break one boundary at a time and verify the promised user state.

Match adapters to application boundaries

Editor integrations, data bindings, and framework adapters can dominate delivery cost. Inventory the actual surfaces: rich text, canvas objects, forms, comments, files, and cross-document references. For each one, record the supported adapter, its maintenance state, escape hatches, and how much domain state it exposes.

Ground merge expectations in causal ordering and Lamport clocks without assuming that all CRDTs expose the same history or conflict interface. The application still needs rules for invariants that span objects or documents. A merge that is mathematically valid may create a product state that requires repair or explanation.

The Yjs vs Automerge matrix uses document shape, history, storage, transport, editor adapter, presence, export, and recovery as separate rows. It records “unknown” when primary documentation does not answer a workload-specific question. Unknown values trigger a prototype; they never default toward the library with a more familiar name. This makes the comparison actionable without fabricating certainty. Add replacement cost to the adapter row: identify which domain objects and UI commands stay independent of the CRDT library. A narrow translation layer cannot erase semantic differences, but it can keep those differences from leaking through every component.

Local-first capability evidence matrixDocument shape, history, storage, transport, editor fit, presence, export, and recovery remain separate evidence rows with unknowns visible.capabilityYjs evidenceAutomerge evidenceproduct proofdocument modelhistorystorage + transporteditor adapterpresenceexportrecovery
Local-first capability evidence matrix
Document shape, history, storage, transport, editor fit, presence, export, and recovery remain separate evidence rows with unknowns visible.
Evidence, not winner points
CapabilityDocumented evidencePrototype needed
Document updatesYjs update APIWorkload boundaries
History and RepoAutomerge conceptsProduct history UI
Editor fitAdapter inventoryActual schema and interactions
RecoveryLibrary mechanismsEnd-to-end restore drill
Figure 2: An unknown stays unknown and triggers a prototype instead of receiving a default score.

Plan history, compaction, export, and recovery

Local-first data lives long enough to accumulate changes, schema revisions, and abandoned devices. Define compaction triggers, checkpoint format, migration path, and a recovery drill. Decide whether a compacted state preserves the history users expect and whether an old client can rejoin safely.

Export is part of ownership. Specify a documented format that can be produced without a live server, plus the metadata needed to understand document boundaries and attachments. Test import into a fresh profile. A proprietary binary snapshot may be acceptable internally, but it is not by itself a user-facing portability story.

Yjs vs Automerge should include corrupt local storage, missing remote history, duplicate delivery, and long-offline peers in the prototype plan. Library-level idempotence helps, yet the product still owns backups, adapter errors, and operator tools. The third figure sequences checkpoint, compact, restore, and resync while labeling which layer must prove each transition. A recovery receipt should name the pre-failure state, injected fault, surviving local data, restored remote data, convergence check, and user-visible result. “It synced again” is too weak to prove that no accepted edit disappeared.

Run the capability-evidence decision fixture

The Node artifact contains an explicit capability matrix derived from the three cited sources and three synthetic product workloads. Each workload supplies required, preferred, and unknown capabilities. The decision engine prints matching evidence rows, rejected alternatives, unresolved questions, and a recommendation.

One workload emphasizes a mature collaborative editor and update transport, one emphasizes inspectable document history and Repo-style adapters, and one remains tied because its decisive recovery behavior is product-specific. The tied Yjs vs Automerge case must return “prototype-both.” Corrupting a required evidence row also forces a prototype instead of letting a default score choose.

The artifact does not import Yjs or Automerge, measure performance, prove privacy, or validate adapters. Its oracle is the consistency of a transparent decision policy. Tests recompute every score from the printed matrix and verify that unsupported requirements cannot produce a winner. That limited tool is useful at the planning stage because it turns missing evidence into scheduled work.

Runnable artifact — The artifact does not execute either library, measure performance, prove privacy, or declare a universal winner. It structures a product decision and requires a prototype when evidence is incomplete.

import assert from "node:assert/strict";
const urls={yjs:"https://docs.yjs.dev/api/document-updates",automerge:"https://automerge.org/docs/reference/concepts/",localFirst:"https://www.inkandswitch.com/essay/local-first/"};
const source=(url,evidence,boundary)=>({url,evidence,boundary,verifiedOn:"2026-09-05"});
const capabilities={
  yjs:{
    documentShape:{value:"Y.Doc shared types and application-chosen document boundaries",status:"documented",signals:["shared-types"],source:source(urls.yjs,"Document updates encode changes from a Y.Doc.","The cited page does not decide product document granularity.")},
    history:{value:"binary update history; user-facing history is product-owned",status:"documented",signals:["merge-updates-without-document"],source:source(urls.yjs,"The alternative update API can merge updates without loading a Y.Doc.","This is update-byte handling, not an automatic history UI.")},
    storage:{value:"product-owned durable store for document updates",status:"documented",signals:["binary-update-storage"],source:source(urls.yjs,"Updates are binary and may be merged or diffed.","Backend choice, retention, encryption, and backup are outside the cited API.")},
    transport:{value:"product-owned transport carrying commutative, associative, idempotent updates",status:"documented",signals:["commutative-updates","state-vector-diff"],source:source(urls.yjs,"The update API documents order-independent application and state-vector diffing.","Authentication, connection lifecycle, and provider operations remain product work.")},
    editor:{value:"workload-specific adapter fit unknown until prototype",status:"unknown",signals:[],source:source(urls.yjs,"No editor adapter is evaluated by this cited page.","Absence from this source is unknown, not evidence of no adapter.")},
    presence:{value:"product-owned ephemeral presence path",status:"unknown",signals:[],source:source(urls.localFirst,"Local-first qualities extend beyond merge semantics.","This matrix does not evaluate a Yjs awareness provider.")},
    export:{value:"product-owned user export; binary document state is not a user format",status:"unknown",signals:[],source:source(urls.yjs,"The API encodes document state as updates.","A user-readable export contract is not specified.")},
    recovery:{value:"product-owned backup, restore, and convergence drill",status:"unknown",signals:[],source:source(urls.localFirst,"Longevity and ownership are product qualities.","No end-to-end Yjs recovery run is performed.")}
  },
  automerge:{
    documentShape:{value:"immutable Automerge documents composed from changes",status:"documented",signals:["change-history"],source:source(urls.automerge,"The concepts page describes documents, changes, and history.","The product still chooses document boundaries.")},
    history:{value:"document change history; history UI and retention are product-owned",status:"documented",signals:["document-change-history"],source:source(urls.automerge,"The concepts page describes document history and changes.","History storage does not itself provide a product audit UI.")},
    storage:{value:"Repo storage-adapter abstraction plus product-selected backend",status:"documented",signals:["repo-storage-adapter"],source:source(urls.automerge,"Automerge Repo is described with storage adapters.","Adapter availability is not durability or backup proof.")},
    transport:{value:"Repo network-adapter abstraction and sync protocol",status:"documented",signals:["repo-network-adapter","sync-protocol"],source:source(urls.automerge,"Automerge Repo is described with network adapters and synchronization.","Authentication and production reconnect behavior remain product obligations.")},
    editor:{value:"workload-specific editor fit unknown until prototype",status:"unknown",signals:[],source:source(urls.automerge,"The cited concepts page does not validate this product's editor schema.","Unknown fit is not a negative capability claim.")},
    presence:{value:"product-owned ephemeral presence path",status:"unknown",signals:[],source:source(urls.localFirst,"Collaboration is a product quality broader than a CRDT document.","The cited Automerge page does not prove product presence behavior.")},
    export:{value:"product-owned user export over an Automerge document",status:"unknown",signals:[],source:source(urls.automerge,"Documents and changes are described.","A user-readable export contract is not specified.")},
    recovery:{value:"product-owned backend restore and peer convergence drill",status:"unknown",signals:[],source:source(urls.localFirst,"Data longevity is a local-first goal.","No end-to-end Automerge recovery run is performed.")}
  }
};
const dimensions=["documentShape","history","storage","transport","editor","presence","export","recovery"];
const workloads=[
  {id:"binary-update-relay",requiredSignals:["merge-updates-without-document","state-vector-diff"],preferredSignals:["commutative-updates"],prototypeDimensions:[]},
  {id:"inspectable-history-app",requiredSignals:["document-change-history"],preferredSignals:["repo-storage-adapter"],prototypeDimensions:["history","export","recovery"]},
  {id:"regulated-field-notes",requiredSignals:[],preferredSignals:["binary-update-storage","repo-storage-adapter"],prototypeDimensions:["editor","presence","export","recovery"]}
];
function decide(workload,matrix=capabilities){const options=Object.entries(matrix).map(([name,rows])=>{const evidenceErrors=dimensions.flatMap(dimension=>{const row=rows[dimension];if(!row)return[dimension+":row-null"];if(!row.source?.url||!row.source?.evidence||!row.source?.boundary)return[dimension+":evidence-null"];return[]});const signals=new Set(dimensions.flatMap(dimension=>rows[dimension]?.signals||[]));const unresolvedRequired=workload.requiredSignals.filter(signal=>!signals.has(signal));const score=workload.preferredSignals.filter(signal=>signals.has(signal)).length;const unknowns=workload.prototypeDimensions.filter(dimension=>rows[dimension]?.status==="unknown");return{name,score,evidenceErrors,unresolvedRequired,unknowns,eligible:evidenceErrors.length===0&&unresolvedRequired.length===0}});const eligible=options.filter(option=>option.eligible),best=Math.max(-1,...eligible.map(option=>option.score)),leaders=eligible.filter(option=>option.score===best),winner=leaders.length===1&&leaders[0].unknowns.length===0?leaders[0].name:null;return{id:workload.id,recommendation:winner||"prototype-both",winnerBasis:winner?"One option has complete cited evidence for every required signal, no declared prototype unknown, and the highest preference score.":"Required evidence is tied, incomplete, or still workload-specific.",options,unresolved:[...new Set(options.flatMap(option=>[...option.evidenceErrors,...option.unresolvedRequired,...option.unknowns]))]}}
const decisions=workloads.map(workload=>decide(workload));assert.equal(decisions[0].recommendation,"yjs");assert.equal(decisions[1].recommendation,"prototype-both");assert.equal(decisions[2].recommendation,"prototype-both");
const corrupted=structuredClone(capabilities);corrupted.yjs.transport.source=null;corrupted.automerge.transport.source.evidence=null;const hostile=decide(workloads[0],corrupted);assert.ok(hostile.options.every(option=>option.evidenceErrors.includes("transport:evidence-null")));assert.equal(hostile.recommendation,"prototype-both");
const receipt={schema:"local-first-crdt-decision-v2",provenance:"Synthetic product workloads and an explicit capability matrix bounded to the three exact cited URLs; no library execution or telemetry.",claimBoundary:"Decision structure only; no performance, privacy, adapter, or universal-winner claim. Unknown means the cited evidence does not answer the product question.",dimensions,sourceUrls:urls,capabilities,workloads,decisions,hostile,productOwnedResponsibilities:{storage:"backend selection, retention, encryption, backup",transport:"identity, authorization, reconnect policy, service operation",presence:"ephemeral state policy and privacy",export:"user-readable schema and portability",recovery:"restore drill and user-visible convergence"}};
console.log(JSON.stringify(receipt,null,2));console.log("PASS: complete CRDT matrix selects one evidenced case and prototypes every unresolved workload");

Prototype both when evidence is incomplete

A good prototype uses the same product slice in both libraries. Include representative document size, editor actions, offline duration, reconnection, storage restart, export, and recovery. Instrument payload size and latency only after the correctness path is stable, and report environmental details with any benchmark.

Choose when one option satisfies the hard constraints and retires the highest risks with acceptable operating cost. If both remain plausible, prefer the simpler product boundary or continue the prototype; do not manufacture precision from weighted preferences. The Yjs vs Automerge decision should remain revisable when library APIs or recovery requirements change.

The practical output is not a winner badge. It is a map of known capabilities, product-owned layers, unknowns, and the smallest experiments that resolve them. Keep exit criteria with the prototype: required workflows, hostile restarts, export inspection, ownership review, and the unanswered questions that would block adoption. Capture failures as evidence rather than smoothing them into a total score. Name the date and dependency versions on the final decision record; ecosystem fit can change while the product workload stays fixed over time. Score one real workload, then prototype both libraries wherever the evidence remains tied.

Checkpoint, compaction, and recovery sequenceLocal document, durable store, sync service, backup, and restored peer exchange named checkpoints without confusing library and product ownership.documentlocal storesyncbackupnew peercheckpointcompact + uploadrestoreresync proofexport + user validation
Checkpoint, compaction, and recovery sequence
Local document, durable store, sync service, backup, and restored peer exchange named checkpoints without confusing library and product ownership.
  1. Checkpoint the local document with schema and library version.
  2. Compact under an explicit history-retention policy.
  3. Back up the durable representation and attachments.
  4. Restore into a fresh peer, then resynchronize.
  5. Export into a documented product format and validate it.
Figure 3: Recovery succeeds only when library state and product-owned storage are tested together.