CRDT & Real-Time Collaboration
This document explains how we use Yjs CRDTs for real-time collaborative editing.
What are CRDTs?
CRDTs (Conflict-free Replicated Data Types) are data structures that can be replicated across multiple nodes, edited independently, and merged automatically without conflicts.
Why Yjs?
Yjs is a high-performance CRDT implementation for JavaScript.
| Feature | Benefit |
|---|---|
| Text CRDT | Perfect for rich text editing |
| Compact encoding | Small update vectors |
| Automatic merge | No conflicts ever |
| Lexical binding | Native integration via @lexical/yjs |
Architecture
Real-Time WebSocket Sync
Unlike polling-based sync, Yjs collaboration happens in real-time:
- Client connects to
/yjs/:roomIdWebSocket endpoint - YjsRoom Durable Object manages the room
- Updates broadcast instantly to all connected clients
- State persisted to D1 on disconnect
Implementation
Backend: YjsRoom Durable Object
// YjsRoom.ts
export class YjsRoom {
private doc: Y.Doc;
private sessions: Set<WebSocket>;
async fetch(request: Request): Promise<Response> {
const roomId = new URL(request.url).searchParams.get('room');
// Load state from D1 on first connection
if (this.sessions.size === 0) {
await this.loadFromD1();
}
// Handle WebSocket
const [client, server] = Object.values(new WebSocketPair());
this.handleSession(server);
return new Response(null, { status: 101, webSocket: client });
}
private handleSession(ws: WebSocket) {
ws.accept();
this.sessions.add(ws);
// Send current state to new client
const state = Y.encodeStateAsUpdate(this.doc);
this.sendMessage(ws, MSG_SYNC_RESPONSE, state);
ws.addEventListener('message', (event) => {
// Apply update and broadcast to others
Y.applyUpdate(this.doc, payload, 'client');
this.broadcast(payload, ws);
});
}
}
Frontend: YjsProvider
// YjsProvider.tsx
export function YjsProvider({ roomId, children }: Props) {
const [doc] = useState(() => new Y.Doc());
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
const ws = new WebSocket(`${API_URL}/yjs/${roomId}`);
ws.onmessage = (event) => {
const payload = decode(event.data);
Y.applyUpdate(doc, payload, 'remote');
};
// Send local updates to server
doc.on('update', (update, origin) => {
if (origin !== 'remote') {
ws.send(encode(update));
}
});
return () => ws.close();
}, [roomId]);
return (
<YjsContext.Provider value={{ doc, isConnected }}>
{children}
</YjsContext.Provider>
);
}
Frontend: CollaborativeLexicalEditor
// CollaborativeLexicalEditor.tsx
export default function CollaborativeLexicalEditor({ placeholder }) {
const { doc, isConnected } = useYjsProvider();
return (
<LexicalComposer initialConfig={config}>
<ToolbarPlugin />
<RichTextPlugin ... />
<CollaborationPlugin
id="main"
providerFactory={(id, yjsDocMap) => {
yjsDocMap.set(id, doc);
return new LexicalYjsProvider(doc);
}}
/>
</LexicalComposer>
);
}
Room Naming Convention
Each collaborative document uses a unique room ID:
| Entity | Room ID Pattern | Example |
|---|---|---|
| Customer Notes | customer-notes-{id} | customer-notes-abc123 |
| Product Description | product-desc-{id} | product-desc-xyz789 |
D1 Persistence
Yjs state is persisted to the YjsRooms table:
CREATE TABLE YjsRooms (
RoomId TEXT PRIMARY KEY,
YjsState TEXT, -- Base64-encoded Yjs state
updated_at INTEGER
);
Persistence is triggered:
- When last client disconnects
- After 1 second of inactivity (debounced)
Connection Status UI
The collaborative editor shows connection status:
| Status | Meaning |
|---|---|
| 🟢 Live | Connected and synced |
| 🟡 Syncing | Receiving initial state |
| 🔴 Offline | Disconnected (edits will sync on reconnect) |
Conflict Scenarios
| Scenario | Resolution |
|---|---|
| Both users type at different positions | Both edits preserved |
| Both users type at same position | Characters interleaved (deterministic by client ID) |
| User A deletes while User B edits | Edit preserved if before deletion |
| Network disconnect/reconnect | All offline edits merge seamlessly |
Testing Collaboration
- Open same customer in two browser windows
- Navigate to the update page
- Type in Notes in one window
- Watch characters appear in the other window in real-time
- Disconnect one client (close tab, kill tauri)
- Make edits while disconnected
- Reconnect and see all edits merged
Best Practices
- Use unique room IDs: Always include the entity ID in the room name
- Handle disconnection gracefully: Show offline indicator to users
- Keep documents focused: One Yjs room per field, not per entity
- Monitor persistence: Check D1 for
YjsRoomsentries