HomeJournalThis post

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.

JP
JP Casabianca
UI/UX designer and full-stack engineer · Bogotá

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.

Yjs local-first editor offline mergeTwo peers make different edits while offline and converge on one Yjs document after reconnect, while awareness stays outside durable state. PEER A · OFFLINEinsert headingPEER B · OFFLINEedit paragraphMERGED Y.DOCequal state vectorsawareness excluded
Figure 1: Offline branches exchange commutative updates; cursor awareness expires on a separate channel.

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.

  1. 1Seed

    Open two peers

  2. 2Partition

    Edit independently

  3. 3Reload

    Restore local state

  4. 4Reconnect

    Assert convergence

Figure 3: The acceptance test makes disconnection and reload first-class states.

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.

LayerCarriesSurvives reloadAuthority
Y.DocContent updatesVia providerDocument schema
y-indexeddbLocal update logYes, if retainedBrowser origin
y-websocketUpdate relayConnection onlyServer room
AwarenessCursor + presenceNoLive peers
Figure 2: Document, persistence, transport, and awareness each carry a different lifetime.

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.

DecisionEvidence retainedStop condition
Define the Yjs local-first editor invariantinitial update, peer IDs, document schema version, partition window, final state vectors, and rendered text digestthe test checks one browser's DOM without comparing the converged document state
Separate durable content from awarenessshared types and schema on one side, awareness fields and timeout policy on the other, with no overlapping durable keyspresence packets mutate business content or grant write access
Persist local updates with y-indexeddbdocument namespace, provider sync event, pre-reload state vector, post-reload vector, storage estimate, and cleanup rulethe product calls a transient browser cache a guaranteed backup
Reconnect through y-websocket without replacing stateroom name, connection epochs, update counts and hashes, duplicate deliveries, final vectors, and server retention configurationreconnect discards the local Y.Doc or replaces it with a server snapshot without merge
Test concurrent structure, not only appended textconcurrent operation set, delivery permutations, converged encoded state, rendered structure, selection outcome, and any domain conflict flagone arrival order passes while a shuffled order yields a different document
Keep authorization outside the CRDT mergeauthenticated room binding, membership version, queued-update decision, user recovery path, and security audit eventpossession of an old room name is accepted as indefinite write authority
Observe convergence without surveilling prosecontent-free state metrics, provider lifecycle events, storage failures, authorization outcomes, and a redaction-reviewed support exportraw collaborative content is copied into analytics or exception logs
Release with a partition-and-recovery drillbrowser and library versions, fixture seed, partition timing, operation log, reload proof, convergence comparison, and awareness expirya scripted online demo is the only evidence for offline durability
Yjs local-first editor decision ledger. The Yjs local-first editor ledger distinguishes convergent document history from ephemeral presence and server authorization.

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.