Introduction
Building real-time collaborative applications like Google Docs or Figma is notoriously difficult. CRDTs (Conflict-free Replicated Data Types) and Yjs make this achievable without complex operational transformation algorithms. This guide covers everything you need to know.
Understanding CRDTs
The Collaboration Problem
When multiple users edit simultaneously, conflicts arise:
graph LR
UserA[User A: "Hello"] -->|edit| Doc1[(Document)]
UserB[User B: "World"] -->|edit| Doc1
Doc1 -->|conflict| Issue[โ Conflict!]
What are CRDTs?
CRDTs are data structures that can be merged automatically, even when edited concurrently:
| Approach | Description | Complexity |
|---|---|---|
| Locking | One user at a time | Simple, blocking |
| OT (Operational Transform) | Transform operations | Complex |
| CRDT | Mathematically mergeable | Moderate |
graph LR
UserA[User A: "Hello"] --> Doc[(Yjs Doc)]
UserB[User B: "World"] --> Doc
Doc -->|auto-merge| Result[โ
"Hello World"]
Types of CRDTs
| Type | Use Case | Examples |
|---|---|---|
| G-Counter | Counters | Likes, votes |
| LWW-Register | Single values | User name, settings |
| OR-Set | Sets | Tags, shared items |
| RGA | Sequences | Text, lists |
Yjs Deep Dive
What is Yjs?
Yjs is a high-performance CRDT implementation:
- Works in browser, Node.js, Bun, Deno
- Framework integrations (React, Vue, Svelte)
- Persistence providers
- Network providers
// Simple Yjs usage
import * as Y from 'yjs';
const ydoc = new Y.Doc();
// Get or create a shared type
const ytext = ydoc.getText('message');
// Observe changes
ytext.observe(event => {
console.log('Text changed:', ytext.toString());
});
// Make changes
ytext.insert(0, 'Hello');
ytext.insert(5, ' World');
Shared Types
// Text - for editors
const ytext = ydoc.getText('document');
// Array - for lists
const yarray = ydoc.getArray('items');
// Map - for key-value
const ymap = ydoc.getMap('settings');
// Object - nested data
const yobj = ydoc.getObject('user');
// XML Fragment - rich text
const yxml = ydoc.getXmlFragment('content');
Basic Operations
// Text operations
const ytext = ydoc.getText('doc');
// Insert
ytext.insert(0, 'Hello World');
ytext.delete(0, 5); // Delete 5 chars from position 0
// Get content
console.log(ytext.toString());
// Get length
console.log(ytext.length);
Array Operations
const yarray = ydoc.getArray('items');
// Push to end
yarray.push(['item1', 'item2']);
// Insert at position
yarray.insert(0, ['newItem']);
// Delete
yarray.delete(0, 1); // Delete 1 item at index 0
// Observe
yarray.observe(event => {
event.changes.delta.forEach(change => {
if (change.insert) console.log('Inserted:', change.insert);
if (change.delete) console.log('Deleted:', change.delete);
});
});
Map Operations
const ymap = ydoc.getMap('settings');
// Set values
ymap.set('theme', 'dark');
ymap.set('notifications', true);
// Get value
console.log(ymap.get('theme'));
// Delete
ymap.delete('notifications');
// Observe
ymap.observe(event => {
event.keys.forEach(key => {
console.log(`${key}: ${ymap.get(key)}`);
});
});
Real-Time Collaboration
WebSocket Provider
// Server using y-websocket
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';
const wss = new WebSocketServer({ port: 1234 });
wss.on('connection', (ws, req) => {
setupWSConnection(ws, req);
});
// Client
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const ydoc = new Y.Doc();
const ytext = ydoc.getText('document');
// Connect to WebSocket server
const provider = new WebsocketProvider(
'ws://localhost:1234',
'my-room',
ydoc
);
// Observe connection status
provider.on('status', event => {
console.log('Connection:', event.status);
});
// The document syncs automatically!
ytext.insert(0, 'Hello from client!');
Awareness (Presence)
// Share cursor position and user info
const awareness = provider.awareness;
// Set local user info
awareness.setLocalState({
user: {
name: 'John',
color: '#ff9900',
},
cursor: {
index: 10,
length: 0,
},
});
// Observe others
awareness.on('change', () => {
const states = awareness.getStates();
states.forEach((state, clientId) => {
if (clientId !== awareness.clientID) {
console.log('User:', state.user);
}
});
});
Integration Examples
React
// useYjs hook
import { useState, useEffect, createContext, useContext } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const YjsContext = createContext(null);
export function YjsProvider({ children, room }) {
const [doc] = useState(() => new Y.Doc());
const [provider, setProvider] = useState(null);
useEffect(() => {
const wsProvider = new WebsocketProvider(
'ws://localhost:1234',
room,
doc
);
setProvider(wsProvider);
return () => wsProvider.destroy();
}, [room, doc]);
return (
<YjsContext.Provider value={{ doc, provider }}>
{children}
</YjsContext.Provider>
);
}
// Component using shared text
export function Editor() {
const { doc } = useContext(YjsContext);
const [text, setText] = useState('');
useEffect(() => {
const ytext = doc.getText('content');
const observer = () => {
setText(ytext.toString());
};
ytext.observe(observer);
setText(ytext.toString());
return () => ytext.unobserve(observer);
}, [doc]);
return (
<textarea
value={text}
onChange={e => {
const ytext = doc.getText('content');
ytext.delete(0, ytext.length);
ytext.insert(0, e.target.value);
}}
/>
);
}
Quill Editor
// Yjs with Quill
import Quill from 'quill';
import * as Y from 'yjs';
import { QuillBinding } from 'y-quill';
const ydoc = new Y.Doc();
const ytext = ydoc.getText('quill');
const editor = new Quill('#editor', {
modules: { toolbar: true },
});
// Bind Yjs to Quill
const binding = new QuillBinding(ytext, editor, provider.awareness);
Monaco Editor
// Yjs with Monaco
import * as Y from 'yjs';
import { MonacoBinding } from 'y-monaco';
import * as monaco from 'monaco-editor';
const editor = monaco.editor.create(document.getElementById('editor'), {
value: '',
language: 'javascript',
});
const ydoc = new Y.Doc();
const ytext = ydoc.getText('code');
const binding = new MonacoBinding(
ytext,
editor.getModel(),
new Set([editor]),
provider.awareness
);
Persistence
IndexedDB (Browser)
import { IndexeddbPersistence } from 'y-indexeddb';
const persistence = new IndexeddbPersistence('my-document', ydoc);
persistence.on('synced', () => {
console.log('Content loaded from IndexedDB');
});
LevelDB (Node.js)
import { LeveldbPersistence } from 'y-leveldb';
const persistence = new LeveldbPersistence('./data/leveldb', ydoc);
persistence.on('synced', () => {
console.log('Content loaded from LevelDB');
});
SQLite
import { SqljsPersistence } from 'y-sqljs';
const sqlPromise = initSqlJs({
locateFile: file => `https://sql.js.org/dist/${file}`
});
const persistence = new SqljsPersistence(
sqlPromise,
ydoc,
{ table: 'documents' }
);
HTTP/WebDAV
import { HttpPersistence } from 'y-http';
const persistence = new HttpPersistence(
'https://your-server.com/api/documents',
ydoc
);
Conflict Resolution
Understanding Merging
// Automatic merge example
const ydoc1 = new Y.Doc();
const ydoc2 = new Y.Doc();
// User 1 edits
const text1 = ydoc1.getText('doc');
text1.insert(0, 'Hello');
// User 2 edits simultaneously
const text2 = ydoc2.getText('doc');
text2.insert(0, 'Hi ');
// Merge - both get both changes!
Y.applyUpdate(ydoc1, Y.encodeStateAsUpdate(ydoc2));
Y.applyUpdate(ydoc2, Y.encodeStateAsUpdate(ydoc1));
console.log(ydoc1.getText('doc').toString()); // "Hi Hello"
console.log(ydoc2.getText('doc').toString()); // "Hi Hello"
Custom Merge
// Sometimes you need custom logic
ymap.observe(event => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'update') {
// Custom conflict resolution
const local = change.value;
const remote = fetchRemoteValue(key);
// Use "last write wins" or merge
if (remote.timestamp > local.timestamp) {
ymap.set(key, remote.value);
}
}
});
});
Performance Tips
1. Batching
// Batch multiple changes
ydoc.transact(() => {
for (let i = 0; i < 1000; i++) {
yarray.push([i]);
}
2. Snapshots
```javascript
});
// Save memory with snapshots
const snapshot = Y.snapshot(ydoc);
// Later, restore Y.applySnapshot(ydoc, snapshot);
### 3. Encoding Updates
```javascript
// Only sync what's changed
const update = Y.encodeStateAsUpdate(ydoc);
console.log('Update size:', update.byteLength);
// Apply to other clients
Y.applyUpdate(otherDoc, update);
Use Cases
Collaborative Text Editor
// Build Google Docs in ~100 lines
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
// 1. Create doc
const ydoc = new Y.Doc();
// 2. Connect
const provider = new WebsocketProvider(
'ws://localhost:1234',
'document-room',
ydoc
);
// 3. Create editor
const editor = new Quill('#editor', { theme: 'snow' });
// 4. Bind!
const binding = new QuillBinding(
ydoc.getText('content'),
editor,
provider.awareness
);
// Done! Real-time collaboration works!
Collaborative Drawing
// Yjs for drawing
const ymap = ydoc.getMap('shapes');
function addShape(shape) {
ymap.set(shape.id, shape);
}
function updateShape(id, changes) {
const shape = ymap.get(id);
ymap.set(id, { ...shape, ...changes });
}
// Share with others via WebSocket
// Works with any canvas library!
Yjs vs Alternatives
| Feature | Yjs | Automerge | Riak |
|---|---|---|---|
| Type Support | All CRDTs | Limited | Limited |
| Performance | Fast | Moderate | Slow |
| Size | Small | Large | Large |
| Framework Support | Many | Few | None |
| Active Dev | โ | โ | โ |
Conclusion
Yjs makes real-time collaboration achievable:
- Simple API - just use shared types
- Automatic merging - no conflicts
- Framework agnostic - works everywhere
- Rich ecosystem - editors, providers
- Performant - handles millions of operations
Perfect for: Editors, drawings, games, real-time apps.
Comments