Real-Time Collaboration with CRDTs
How conflict-free replicated data types converge without a central authority: the YATA algorithm, Yjs shared types, update exchange, awareness, and a runnable collaborative editor.
Published on • August 11, 2026
AI Assistant

Two people type into the same field at the same time. The network delivers each keystroke in a different order to each screen, yet every screen must end up showing the identical document — no lost text, no duplicated sentences, no last-write-wins erasing a colleague’s work. Server-authoritative approaches solve this by serializing writes through locks: predictable, but slow, offline-hostile, and a single point of failure.
Conflict-free Replicated Data Types (CRDTs) solve it with math instead of locks. Every replica applies each operation locally, and because operations merge commutatively, every replica converges to the same state no matter the delivery order — even after a peer was offline for hours.
In this post, you will learn how CRDTs achieve convergence, how Yjs implements them via the YATA algorithm with shared types and binary updates, how awareness and undo fit in, and how to build a real-time collaborative editor that survives offline edits.
Key technologies: CRDT theory, Yjs 13, y-websocket and y-indexeddb providers, the Awareness protocol, and ProseMirror/TipTap binding.
Prerequisites
- Node.js 20+ and npm.
- Familiarity with async JavaScript (events, Promises, WebSocket basics).
- Two browser windows — the demo syncs two “clients”.
Why convergence is hard
Naive sync breaks three ways. Last-write-wins (LWW) erases one person’s keystrokes. Whole-string diff-and-patch duplicates or reorders content. A central server serializes everything but breaks offline and at the edges. The shared insight: order of arrival must not matter.
A CRDT guarantees strong eventual consistency — replicas receiving the same operations, in any order and with duplicates, converge. The classic minimal example is the G-Counter: each replica owns one slot, and merge takes the element-wise max:
const replicaA = { counter: [5, 0, 2] }; // own slot 0
const replicaB = { counter: [3, 4, 2] }; // own slot 1
function merge(a, b) {
return { counter: a.counter.map((n, i) => Math.max(n, b.counter[i])) };
}
merge(replicaA, replicaB); // { counter: [5, 4, 2] } — same on both sides
Yjs and the YATA list CRDT
Yjs is an operation-based CRDT for JavaScript implementing an improved variant of the YATA algorithm (Nicolaescu et al., 2016). At its heart it is a list CRDT — text is a list of characters — where every inserted item carries a globally unique (clientID, clock) id. Each item also stores references to its neighbors at insert time: origin (left) and originRight (right).
When two peers insert at the same position concurrently, Yjs doesn’t guess intent — it deterministically orders the two items by comparing origins and IDs, so all replicas resolve the same order. That deterministic tie-break is the whole trick, and it’s a couple of dozen lines in the Item logic rather than an unmaintainable merge heuristic:
// Simplified YATA integration rule
function shouldGoBefore(a, b) {
return compareByOrigin(a, b) || compareIds(a, b); // deterministic
}
Deletions are treated differently from insertions: an item is flagged deleted (a bit in its info field), and tombstones preserve history so offline peers still resolve concurrent edits. In practice deletions collapse into runs — a real trace with 77k deleted characters needs only a ~4.5KB deleted-set snapshot.
Shared types and transactions
Yjs exposes four main shared types, mutated inside transactions only:
import * as Y from 'yjs';
const doc = new Y.Doc();
doc.getMap('meta').set('title', 'Draft');
doc.getArray('checklist').insert(0, ['setup', 'implement']);
const text = doc.getText('body');
text.insert(0, 'Hello collaborator');
console.log(text.toString()); // "Hello collaborator"
All mutations must run inside doc.transact(...) so the document batches local changes into one atomic update, fires observers once, and emits one compressed update message:
doc.transact(() => {
text.insert(0, 'Nice ');
text.delete(5, 3);
}, 'local-origin-tag'); // origin tag rides on the transaction
Observers fire after commit and drive both re-rendering and networking:
text.observe((event, transaction) => {
console.log('changed:', text.toString());
if (transaction.origin !== 'remote') {
sendToNetwork(Y.encodeStateAsUpdate(doc));
}
});
The update protocol: state vectors and deltas
Yjs encodes document state as binary updates — byte arrays of new items and deletions. Updates are commutative, associative, and idempotent: apply them in any order, any number of times, and replicas converge. This is the property that makes offline merge “just work”:
doc1.transact(() => doc1.getText('t').insert(0, 'Hello'));
doc2.transact(() => doc2.getText('t').insert(0, 'World'));
// Exchange updates — order does not matter
Y.applyUpdate(doc1, Y.encodeStateAsUpdate(doc2));
Y.applyUpdate(doc2, Y.encodeStateAsUpdate(doc1));
doc1.getText('t').toString() === doc2.getText('t').toString(); // true
For efficient initial sync, Yjs keeps a state vector — how many operations each client has produced. Send it to a peer and get back only the delta:
const remoteState = Y.encodeStateVector(remoteDoc);
const delta = Y.encodeStateAsUpdate(remoteDoc, remoteState); // only missing ops
Y.applyUpdate(localDoc, delta);
Y.mergeUpdates compresses multiple updates into one, and Y.encodeStateVectorFromUpdate computes vectors straight from binary data — sync without loading the document.
Providers and awareness
A provider wires a Y.Doc to the network. y-websocket relays updates through a simple server, y-webrtc connects peers directly, and y-indexeddb persists the document in the browser:
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
const provider = new WebsocketProvider('wss://sync.example.com', 'doc-room-1', doc);
const persistence = new IndexeddbPersistence('doc-room-1', doc);
provider.on('status', (e) => console.log(e.status)); // "connected"
Awareness is a separate tiny CRDT for ephemeral presence — cursors, selections, “who is here” — not part of document history:
const awareness = provider.awareness;
awareness.setLocalStateField('user', { name: 'Ada', color: '#e91e63' });
awareness.on('change', () => renderCursors(Array.from(awareness.getStates().values())));
Undo and rich text
Undo in collaborative systems is famously hard — inverting the last local operation breaks concurrent edits. Y.UndoManager tracks per-author transactions so you undo only your own recent work, on your own replica:
const undoManager = new Y.UndoManager(text, {
trackedOrigins: new Set(['local-origin-tag']),
captureTimeout: 500, // coalesce a keystroke burst into one undo step
});
undoManager.undo();
undoManager.redo();
For rich text, y-prosemirror binds a ProseMirror/TipTap editor to a Y.XmlFragment, tracking formatting as inline item attributes — two people styling the same sentence concurrently keep both marks.
Putting It All Together
The runnable version — a minimal collaborative editor with two browser clients syncing through a y-websocket relay, IndexedDB persistence, and per-author undo — is in this gist: https://gist.github.com/redlinesoft/yjs-collaborative-editor
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const doc = new Y.Doc();
const text = doc.getText('prose');
const provider = new WebsocketProvider('ws://localhost:1234', 'notes', doc);
text.insert(0, 'offline-first collaboration');
Run npx y-websocket-server, open two tabs on the same room, type in each — then kill one tab’s network and type anyway. Expected output: on reconnect both documents converge with nothing lost, because the exchanged deltas carried the missing operations.
Conclusion & Next Steps
You understand why concurrent edits converge: CRDT merge is commutative, Yjs realizes it via YATA with deterministic item ordering, updates are idempotent binary deltas, and providers plus awareness handle transport and presence. Next: bind a real editor with y-prosemirror, add an authenticated y-websocket server, evaluate Hocuspocus for server persistence, and measure update sizes with Y.mergeUpdates.
References / Sources
- Yjs repository — shared types, providers, reference implementation. https://github.com/yjs/yjs
- Yjs documentation and internals. https://docs.yjs.dev/api/internals
- The YATA paper: “Near Real-Time Peer-to-Peer Shared Editing on Extensible Data Types”. https://www.researchgate.net/publication/310212186_Near_Real-Time_Peer-to-Peer_Shared_Editing_on_Extensible_Data_Types
- Automerge — an alternative (RGA-based) CRDT for comparison. https://github.com/automerge/automerge