Skip to main content
Version: 0.8.0

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.

FeatureBenefit
Text CRDTPerfect for rich text editing
Compact encodingSmall update vectors
Automatic mergeNo conflicts ever
Lexical bindingNative integration via @lexical/yjs

Architecture​

Real-Time WebSocket Sync​

Unlike polling-based sync, Yjs collaboration happens in real-time:

  1. Client connects to /yjs/:roomId WebSocket endpoint
  2. YjsRoom Durable Object manages the room
  3. Updates broadcast instantly to all connected clients
  4. 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:

EntityRoom ID PatternExample
Customer Notescustomer-notes-{id}customer-notes-abc123
Product Descriptionproduct-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:

StatusMeaning
🟢 LiveConnected and synced
🟡 SyncingReceiving initial state
🔴 OfflineDisconnected (edits will sync on reconnect)

Conflict Scenarios​

ScenarioResolution
Both users type at different positionsBoth edits preserved
Both users type at same positionCharacters interleaved (deterministic by client ID)
User A deletes while User B editsEdit preserved if before deletion
Network disconnect/reconnectAll offline edits merge seamlessly

Testing Collaboration​

  1. Open same customer in two browser windows
  2. Navigate to the update page
  3. Type in Notes in one window
  4. Watch characters appear in the other window in real-time
  5. Disconnect one client (close tab, kill tauri)
  6. Make edits while disconnected
  7. Reconnect and see all edits merged

Best Practices​

  1. Use unique room IDs: Always include the entity ID in the room name
  2. Handle disconnection gracefully: Show offline indicator to users
  3. Keep documents focused: One Yjs room per field, not per entity
  4. Monitor persistence: Check D1 for YjsRooms entries