Skip to content

Rich text with Yjs

Character-level collaborative editing is the one place where last-write-wins or guarded patches are clearly the wrong tool: two people typing in the same paragraph should both win. For that, datadata embeds Yjs — a mature CRDT implementation — rather than reinventing text merging.

A document’s structured data is JSON, changed by guarded patches. Where a field needs collaborative text, it holds a reference to an embedded Yjs document:

{ "title": "Roadmap", "body": "yjs_a1b2c3" }

The Yjs document is created alongside its parent and travels with it: subscribing to the parent delivers the Yjs state too, and a single wire protocol update can carry a JSON patch and Yjs deltas together. But the two are versioned independently — the document’s sequence tracks the JSON snapshot only, and Yjs deltas advance their own per-document cursor. That split, and what it fixes, has its own page.

Sessions work with real Y.Doc instances. session.getYDoc({ docId, yjsId }) returns the live one — a single shared instance per target, so every editor bound to it sees the others’ keystrokes synchronously. Local edits are captured and auto-synced (debounced), remote deltas apply as they arrive, and the whole Yjs editor ecosystem (ProseMirror/Tiptap bindings, etc.) takes the instance as-is. For cursors, the same pair gets a real y-protocols Awareness — bridged over presence, with no second wire protocol.

The lifecycle follows the reference. A Y.Doc is authored in the same write that adds its reference field (the create/update callback’s accessor), so reference and content are never transiently inconsistent — and a reference whose Y.Doc was never written simply initializes empty on first use. Removing the referencing field deletes the orphaned Y.Doc from storage (a tombstone in the Yjs lane’s log). Reintroducing the same reference id later does not recover the deleted content — the tombstone stands; the id merely becomes reachable again and initializes empty on first use, exactly like a never-written reference. Loading granularity is the document: subscribing delivers all of a document’s embedded Y.Docs — full states on first contact, exact state-vector diffs on every reconnect after — so a document with many large texts should be modeled as more documents.

The same model holds inside a staging session: session.getYDoc hands an editor a staged Y.Doc to bind to, so rich text can be drafted and reviewed before it commits.

These Y.Docs are all durable — stored, versioned, replayable. The opposite case — transient text needed only while it is produced, like an LLM response streamed token by token — instead rides an ephemeral Y.Doc owned by a presence cell, never stored and reaped when the cell goes; see Presence for that lane.

Structured data Rich text
Format JSON + RFC 6902 patches Yjs binary updates
Concurrency Server-ordered, guards detect conflicts CRDT, always converges
Conflict surface Explicit — rejected guards, staged conflict previews None — merging is automatic

This split is a deliberate design decision: structure benefits from explicit, reviewable conflicts; prose benefits from silent convergence.

The boundary extends to validation: the schema’s jurisdiction deliberately ends where Yjs begins. Validation governs the JSON structure (including that a field holds a Yjs reference); constraints on the collaborative text content — length, structure, formatting rules — are intentionally left to the application and its editor.