Yjs CRDT Patterns
Core Concepts
Shared Types
Yjs provides six shared types. You'll mostly use three:
-
Y.Map
-
Key-value pairs (like JavaScript Map)
-
Y.Array
-
Ordered lists (like JavaScript Array)
-
Y.Text
-
Rich text with formatting
The other three (Y.XmlElement , Y.XmlFragment , Y.XmlText ) are for rich text editor integrations.
Client ID
Every Y.Doc gets a random clientID on creation. This ID is used for conflict resolution—when two clients write to the same key simultaneously, the higher clientID wins, not the later timestamp.
const doc = new Y.Doc(); console.log(doc.clientID); // Random number like 1090160253
From dmonad (Yjs creator):
"The 'winner' is decided by ydoc.clientID of the document (which is a generated number). The higher clientID wins."
— GitHub issue #520
The actual comparison in source (updates.js#L357):
return dec2.curr.id.client - dec1.curr.id.client; // Higher clientID wins
This is deterministic (all clients converge to same state) but not intuitive (later edits can lose).
Shared Types Cannot Move
Once you add a shared type to a document, it can never be moved. "Moving" an item in an array is actually delete + insert. Yjs doesn't know these operations are related.
Critical Patterns
- Single-Writer Keys (Counters, Votes, Presence)
Problem: Multiple writers updating the same key causes lost writes.
// BAD: Both clients read 5, both write 6, one click lost function increment(ymap) { const count = ymap.get('count') || 0; ymap.set('count', count + 1); }
Solution: Partition by clientID. Each writer owns their key.
// GOOD: Each client writes to their own key function increment(ymap) { const key = ymap.doc.clientID; const count = ymap.get(key) || 0; ymap.set(key, count + 1); }
function getCount(ymap) { let sum = 0; for (const value of ymap.values()) { sum += value; } return sum; }
- Fractional Indexing (Reordering)
Problem: Drag-and-drop reordering with delete+insert causes duplicates and lost updates.
// BAD: "Move" = delete + insert = broken function move(yarray, from, to) { const [item] = yarray.delete(from, 1); yarray.insert(to, [item]); }
Solution: Add an index property. Sort by index. Reordering = updating a property.
// GOOD: Reorder by changing index property function move(yarray, from, to) { const sorted = [...yarray].sort((a, b) => a.get('index') - b.get('index')); const item = sorted[from];
const earlier = from > to;
const before = sorted[earlier ? to - 1 : to];
const after = sorted[earlier ? to : to + 1];
const start = before?.get('index') ?? 0;
const end = after?.get('index') ?? 1;
// Add randomness to prevent collisions
const index = (end - start) * (Math.random() + Number.MIN_VALUE) + start;
item.set('index', index);
}
- Nested Structures for Conflict Avoidance
Problem: Storing entire objects under one key means any property change conflicts with any other.
// BAD: Alice changes nullable, Bob changes default, one loses schema.set('title', { type: 'text', nullable: true, default: 'Untitled', });
Solution: Use nested Y.Maps so each property is a separate key.
// GOOD: Each property is independent const titleSchema = schema.get('title'); // Y.Map titleSchema.set('type', 'text'); titleSchema.set('nullable', true); titleSchema.set('default', 'Untitled'); // Alice and Bob edit different keys = no conflict
Storage Optimization
Y.Map vs Y.Array for Key-Value Data
Y.Map tombstones retain the key forever. Every ymap.set(key, value) creates a new internal item and tombstones the previous one.
For high-churn key-value data (frequently updated rows), consider YKeyValue from yjs/y-utility :
// YKeyValue stores {key, val} pairs in Y.Array // Deletions are structural, not per-key tombstones import { YKeyValue } from 'y-utility/y-keyvalue';
const kv = new YKeyValue(yarray); kv.set('myKey', { data: 'value' });
When to use Y.Map: Bounded keys, rarely changing values (settings, config). When to use YKeyValue: Many keys, frequent updates, storage-sensitive.
Epoch-Based Compaction
If your architecture uses versioned snapshots, you get free compaction:
// Compact a Y.Doc by re-encoding current state const snapshot = Y.encodeStateAsUpdate(doc); const freshDoc = new Y.Doc({ guid: doc.guid }); Y.applyUpdate(freshDoc, snapshot); // freshDoc has same content, no history overhead
Common Mistakes
- Assuming "Last Write Wins" Means Timestamps
It doesn't. Higher clientID wins, not later timestamp. Design around this or add explicit timestamps with y-lwwmap .
- Using Y.Array Position for User-Controlled Order
Array position is for append-only data (logs, chat). User-reorderable lists need fractional indexing.
- Forgetting Document Integration
Y types must be added to a document before use:
// BAD: Orphan Y.Map const orphan = new Y.Map(); orphan.set('key', 'value'); // Works but doesn't sync
// GOOD: Attached to document const attached = doc.getMap('myMap'); attached.set('key', 'value'); // Syncs to peers
- Storing Non-Serializable Values
Y types store JSON-serializable data. No functions, no class instances, no circular references.
- Expecting Moves to Preserve Identity
// This creates a NEW item, not a moved item yarray.delete(0); yarray.push([sameItem]); // Different Y.Map instance internally
Any concurrent edits to the "moved" item are lost because you deleted the original.
- Accessing Raw Y.Doc Shared Types for Document Content
Document Y.Docs use a timeline model (Y.Array('timeline') with nested typed entries). Never access raw shared types on the ydoc directly—use the handle methods:
// BAD: bypasses the timeline const ytext = handle.ydoc.getText('content');
// GOOD: use handle methods (timeline-backed) handle.read(); // string I/O handle.write('hello'); handle.asText(); // Y.Text for editor binding handle.asRichText(); // Y.XmlFragment for richtext binding handle.asSheet(); // SheetBinding for spreadsheet binding
See the workspace-api skill for the full DocumentHandle API.
Debugging Tips
Inspect Document State
console.log(doc.toJSON()); // Full document as plain JSON
Check Client IDs
// See who would win a conflict console.log('My ID:', doc.clientID);
Watch for Tombstone Bloat
If documents grow unexpectedly, check for:
-
Frequent Y.Map key overwrites
-
"Move" operations on arrays
-
Missing epoch compaction
References
-
Learn Yjs - Interactive tutorials
-
Yjs Documentation - API reference
-
Yjs INTERNALS.md - How Yjs works internally
-
GitHub issue #520 - Conflict resolution discussion with dmonad
-
yjs/y-utility - YKeyValue and helpers
-
y-lwwmap - Timestamp-based LWW
-
fractional-indexing - Production library
-
YATA paper - Academic foundation