Yjs Local-First Editor With Offline Merge
A Yjs local-first editor persists updates, merges two offline browsers deterministically, separates awareness, and verifies convergence after reconnect.
A Yjs local-first editor should accept edits without a network, persist them across a reload, and converge when peers reconnect. The proof is not that the cursors look synchronized during a happy demo; it is that the document state becomes identical after a deliberate partition.
This tutorial builds a two-browser CRDT editor, uses y-indexeddb for local persistence, reconnects through y-websocket, and keeps presence outside durable content. The fixture is deterministic and modest: it demonstrates merge invariants without inventing production latency results.
Define the Yjs local-first editor invariant
The document must remain editable during a network partition, survive a browser reload from local storage, and converge to the same encoded state after all updates exchange. That invariant is stronger than eventually showing similar text: compare state vectors or encoded updates and the rendered document. Give every test peer a stable fixture identity while keeping real user identity in the authorization layer.
The document schema, editor binding, and provider configuration all influence correctness, so pin them in the receipt. A CRDT resolves concurrent structure; it does not decide whether a user was allowed to write.
Separate durable content from awareness
Yjs shared types carry document state: paragraphs, text marks, block order, or application records. Awareness carries ephemeral peer state such as cursor, selection, display color, and online status. Doc for convenience, because reconnect churn would become durable history and stale cursors could reappear after days offline.
Conversely, do not put document authorization in awareness; peers can disappear without revoking durable permissions. The architecture diagram shows the split as two different lifetimes that merely meet in the UI renderer.
Runnable artifact: The compact CRDT model merges two offline operation sets in opposite arrival orders and confirms awareness never enters the document log. Its local-first-merge.test.mjs receipt keeps the article's simplified boundary executable and reviewable.
Save the inspectable proof as local-first-merge.test.mjs and run node local-first-merge.test.mjs. Expected final line: PASS: offline edits converge.
import assert from "node:assert/strict";
const apply=(state,ops)=>[...new Map([...state,...ops].map(x=>[x.id,x])).values()].sort((a,b)=>a.id.localeCompare(b.id));const base=[{id:"00",char:"A"}],left=[{id:"10-a",char:"L"}],right=[{id:"10-b",char:"R"}];
const mergeA=apply(apply(base,left),right),mergeB=apply(apply(base,right),left);assert.deepEqual(mergeA,mergeB);assert.equal(mergeA.map(x=>x.char).join(""),"ALR");const awareness={peer:"a",cursor:2};assert.equal(mergeA.some(x=>x.cursor),false);assert.equal(awareness.cursor,2);console.log("PASS: offline edits converge");
Persist local updates with y-indexeddb
Doc first, attach y-indexeddb with a stable local document name, and wait for its synced event before declaring persisted state restored. The provider stores Yjs updates in IndexedDB so the tab can reconstruct work without the network. Test a hard reload while disconnected, not only an in-memory offline toggle.
Namespace local data by environment and authorized document identity; sign-out and access revocation need an explicit cleanup policy. Browser storage can be evicted, so export, server synchronization, and product copy must not promise permanent custody from one origin-private store.
Reconnect through y-websocket without replacing state
Doc rather than creating a fresh document on reconnect. During the partition, peer A inserts a heading and peer B revises the first paragraph. When both providers reconnect, updates can arrive in either order and still converge.
Simulate duplicate messages and repeated connect cycles because network layers often redeliver around reconnect. A server can relay and retain updates, but the application still owns room authorization, tenant isolation, retention, compaction, and recovery. The provider's connected indicator is not proof that every peer has observed the latest update.
Test concurrent structure, not only appended text
Appending different characters is the easiest convergence demo and the least representative editor workload. Add fixtures for two peers editing the same word, deleting a block another peer formats, moving adjacent blocks, editing a table cell, and undoing local work after remote updates. Assert the final structure and editor selection recovery, while allowing concurrent semantics to differ from a human's preferred merge.
Yjs can guarantee convergence, not intent preservation. Where a product needs domain conflict rules—such as one published title—model that field deliberately or add a review state rather than blaming the CRDT.
- 1Seed
Open two peers
- 2Partition
Edit independently
- 3Reload
Restore local state
- 4Reconnect
Assert convergence
Keep authorization outside the CRDT merge
Offline collaboration creates a sharp revocation question: a user can author locally after their server access has changed. The transport must authenticate every connection and room, and the server must decide whether queued updates are accepted under current policy. That decision may require quarantining work and offering a copy-out path rather than silently merging or deleting it.
Cryptographic document updates do not carry business authorization by themselves. Record the policy for membership changes, shared devices, encryption, data export, and moderation before a local-first editor reaches sensitive content.
Observe convergence without surveilling prose
Operational telemetry can record update byte counts, state-vector divergence duration, provider reconnects, persistence errors, compaction duration, and rejected authorization events without logging document text. A sampled diagnostic can compare hashes or vector clocks after a controlled fixture. Set alerts on peers that remain divergent after a healthy connection, oversized update histories, IndexedDB failures, and repeated room rejections.
Preserve privacy by keeping content out of ordinary traces. A useful support receipt lets a person export local work and connection diagnostics separately, so troubleshooting does not require surrendering the document itself.
| Layer | Carries | Survives reload | Authority |
|---|---|---|---|
| Y.Doc | Content updates | Via provider | Document schema |
| y-indexeddb | Local update log | Yes, if retained | Browser origin |
| y-websocket | Update relay | Connection only | Server room |
| Awareness | Cursor + presence | No | Live peers |
Release with a partition-and-recovery drill
The release test opens two isolated browser contexts on a known document, confirms local persistence, disconnects both, applies overlapping operations, reloads one peer, reconnects in shuffled order, and waits for equal state vectors. Then it verifies awareness disappears when a peer leaves and returns only after a live presence update. Add an export snapshot and server restore exercise for operational resilience. The editor passes when content converges, permissions hold, local recovery is understandable, and the product clearly describes the limits of browser persistence.
Provider documentation and adjacent browser contracts
Start with the core Yjs documentation, then pin the behaviors claimed for y-indexeddb and y-websocket instead of treating providers as interchangeable plumbing. The surrounding browser work is covered by the site's IndexedDB draft queue, Web Locks coordination, delivery-semantics primer, and JSON Patch history model, each illuminating a responsibility that remains outside the CRDT merge algorithm.
Test the partition, not just two open tabs
Open two browser profiles, let both load the same document, disconnect both, and make overlapping edits that include insertion, deletion, formatting, and undo. Reload one profile while still offline, reconnect the peers in both possible orders, and compare encoded state vectors as well as visible text. A Yjs local-first editor earns confidence when the document converges across that sequence while awareness disappears on disconnect, unauthorized updates are refused at the service boundary, and a user can still export work after provider failure.
Add a schema-version mismatch to the partition drill and decide whether old clients may continue editing, must migrate locally, or become read-only before reconnect. The Yjs local-first editor test should preserve both replicas for diagnosis so a failed migration cannot be mistaken for a CRDT convergence defect or repaired by discarding offline work.
Finally, export the converged document from each profile and compare semantic state after stripping awareness. A second Yjs local-first editor check at this boundary catches application-level serialization differences that matching on-screen text can hide, including lost attributes, divergent embedded objects, and undo history that targets the wrong operation set.
| Decision | Evidence retained | Stop condition |
|---|---|---|
| Define the Yjs local-first editor invariant | initial update, peer IDs, document schema version, partition window, final state vectors, and rendered text digest | the test checks one browser's DOM without comparing the converged document state |
| Separate durable content from awareness | shared types and schema on one side, awareness fields and timeout policy on the other, with no overlapping durable keys | presence packets mutate business content or grant write access |
| Persist local updates with y-indexeddb | document namespace, provider sync event, pre-reload state vector, post-reload vector, storage estimate, and cleanup rule | the product calls a transient browser cache a guaranteed backup |
| Reconnect through y-websocket without replacing state | room name, connection epochs, update counts and hashes, duplicate deliveries, final vectors, and server retention configuration | reconnect discards the local Y.Doc or replaces it with a server snapshot without merge |
| Test concurrent structure, not only appended text | concurrent operation set, delivery permutations, converged encoded state, rendered structure, selection outcome, and any domain conflict flag | one arrival order passes while a shuffled order yields a different document |
| Keep authorization outside the CRDT merge | authenticated room binding, membership version, queued-update decision, user recovery path, and security audit event | possession of an old room name is accepted as indefinite write authority |
| Observe convergence without surveilling prose | content-free state metrics, provider lifecycle events, storage failures, authorization outcomes, and a redaction-reviewed support export | raw collaborative content is copied into analytics or exception logs |
| Release with a partition-and-recovery drill | browser and library versions, fixture seed, partition timing, operation log, reload proof, convergence comparison, and awareness expiry | a scripted online demo is the only evidence for offline durability |
A Yjs local-first editor earns the label by surviving partition, reload, authorization review, and shuffled reconnection. Keep its convergence receipt beside every schema and provider upgrade.