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