ed Types
Instead of managing a single string, you extract a Y.Text instance from the document. This type exposes methods like insert, delete, and format that broadcast atomic intentions rather than full replacements.
Step 3: Establish the Transport Layer
A WebSocket provider bridges the local Y.Doc with remote peers. The provider listens for local changes, serializes them into CRDT update packets, and broadcasts them. It also receives remote updates, applies them to the local document, and triggers observers.
Step 4: Bind to React with Transactional Updates
React's state management must synchronize with the CRDT's internal state. Directly overwriting the CRDT from React state breaks the intention model. Instead, you observe CRDT changes to update React, and you wrap local mutations in Yjs transactions to ensure atomicity and proper undo/redo stack management.
Implementation Architecture
The following TypeScript implementation demonstrates a production-ready pattern. It separates the CRDT lifecycle into a custom hook, uses transactional boundaries for mutations, and properly handles React's rendering cycle.
// hooks/useCollaborativeDocument.ts
import { useEffect, useRef, useCallback } from 'react';
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
interface UseCollabDocOptions {
docId: string;
wsEndpoint: string;
}
export function useCollaborativeDocument({ docId, wsEndpoint }: UseCollabDocOptions) {
const ydocRef = useRef<Y.Doc | null>(null);
const providerRef = useRef<WebsocketProvider | null>(null);
const sharedTextRef = useRef<Y.Text | null>(null);
const onChangeRef = useRef<((text: string) => void) | null>(null);
useEffect(() => {
// 1. Create isolated document instance
const doc = new Y.Doc();
ydocRef.current = doc;
// 2. Extract shared text type
const textType = doc.getText('editor-content');
sharedTextRef.current = textType;
// 3. Initialize WebSocket provider
const provider = new WebsocketProvider(wsEndpoint, docId, doc, {
awareness: new Y.Awareness(doc),
connect: true,
});
providerRef.current = provider;
// 4. Observe remote changes and bubble to React
const handleChange = () => {
if (onChangeRef.current) {
onChangeRef.current(textType.toString());
}
};
textType.observe(handleChange);
return () => {
textType.unobserve(handleChange);
provider.disconnect();
doc.destroy();
ydocRef.current = null;
providerRef.current = null;
sharedTextRef.current = null;
};
}, [docId, wsEndpoint]);
// Transactional mutation wrapper
const applyLocalChange = useCallback((newContent: string) => {
const doc = ydocRef.current;
const textType = sharedTextRef.current;
if (!doc || !textType) return;
// Use transaction to ensure atomicity and proper undo/redo grouping
doc.transact(() => {
const currentLength = textType.length;
textType.delete(0, currentLength);
textType.insert(0, newContent);
});
}, []);
// Expose observer binding for React components
const bindOnChange = useCallback((handler: (text: string) => void) => {
onChangeRef.current = handler;
}, []);
return {
sharedText: sharedTextRef.current,
applyLocalChange,
bindOnChange,
provider: providerRef.current,
};
}
// components/CollaborativeEditor.tsx
"use client";
import { useState, useEffect, useCallback } from 'react';
import { useCollaborativeDocument } from '../hooks/useCollaborativeDocument';
interface EditorProps {
documentId: string;
wsUrl: string;
}
export function CollaborativeEditor({ documentId, wsUrl }: EditorProps) {
const [content, setContent] = useState('');
const { applyLocalChange, bindOnChange, provider } = useCollaborativeDocument({
docId: documentId,
wsEndpoint: wsUrl,
});
// Sync CRDT state to React on mount and remote updates
useEffect(() => {
bindOnChange((updatedText) => {
setContent(updatedText);
});
}, [bindOnChange]);
const handleInput = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setContent(newValue); // Optimistic UI update
applyLocalChange(newValue); // Broadcast CRDT intention
},
[applyLocalChange]
);
return (
<div className="relative">
<textarea
value={content}
onChange={handleInput}
className="w-full h-80 p-4 font-mono text-sm border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Begin collaborative editing..."
/>
<div className="absolute bottom-2 right-2 text-xs text-gray-400">
Status: {provider?.ws?.readyState === 1 ? 'Connected' : 'Disconnected'}
</div>
</div>
);
}
Architecture Decisions & Rationale
- Custom Hook Isolation: The CRDT lifecycle is extracted into
useCollaborativeDocument. This prevents React's strict mode or re-renders from accidentally reinitializing the WebSocket connection or duplicating observers.
- Transactional Mutations:
doc.transact() groups multiple CRDT operations into a single atomic unit. This is critical for undo/redo functionality and ensures that partial updates are never broadcast to peers.
- Observer Pattern over Direct State Binding: React state is updated only when the CRDT emits changes. Local inputs trigger the CRDT, which then emits back to React. This creates a unidirectional data flow that prevents infinite render loops and keeps the CRDT as the single source of truth.
- Awareness Integration: The provider initializes
Y.Awareness. This tracks client metadata (cursors, selection ranges, user presence) without polluting the document state. It is essential for multiplayer UX.
Pitfall Guide
Production CRDT implementations frequently fail due to subtle architectural mismatches between framework state management and decentralized data structures. The following pitfalls represent the most common failure modes observed in deployed systems.
1. Direct State Overwrites Breaking CRDT Intentions
Explanation: Developers often treat Y.Text like a standard string and assign values directly or bypass the CRDT API. This destroys the metadata tags that enable conflict resolution.
Fix: Always use insert, delete, or applyUpdate. Never assign to the underlying value. Wrap mutations in doc.transact().
2. React Strict Mode Double-Initialization
Explanation: React 18+ Strict Mode mounts components twice in development. If the WebSocket provider is initialized inside a component body without proper cleanup guards, it creates duplicate connections and desynchronizes awareness data.
Fix: Use useRef to track initialization state, or rely on the cleanup function in useEffect to guarantee teardown before re-mount. The custom hook pattern above handles this safely.
3. Ignoring Garbage Collection (GC)
Explanation: CRDTs retain metadata for every historical operation to guarantee convergence. Without garbage collection, document size grows linearly with edit history, eventually causing memory exhaustion and sync latency.
Fix: Enable gc: true in Y.Doc options (default in modern Yjs). Periodically call ydoc.gc() or rely on automatic cleanup. For long-lived documents, implement snapshotting and delta compression.
4. Blocking the Main Thread During Heavy Sync
Explanation: When a client reconnects after being offline, it may receive thousands of CRDT updates. Processing them synchronously blocks the JavaScript event loop, freezing the UI.
Fix: Use Y.applyUpdateV2 with chunked processing, or offload CRDT synchronization to a Web Worker. Yjs supports worker-based providers (y-indexeddb + y-websocket worker) for heavy workloads.
5. Mismanaging Awareness Data Lifecycle
Explanation: Awareness data (cursors, presence) is ephemeral. If clients don't properly broadcast heartbeat signals or clean up on disconnect, stale cursors persist indefinitely, confusing users.
Fix: Rely on Yjs's built-in awareness protocol. Ensure your WebSocket server supports awareness broadcasting. Implement a timeout mechanism on the client to hide cursors that haven't updated within a threshold (e.g., 5 seconds).
6. Mixing LWW and CRDT Paradigms
Explanation: Teams sometimes use CRDTs for text but fall back to database overwrites for metadata (e.g., document title, tags). This creates split-brain scenarios where the UI shows one state and the database shows another.
Fix: Commit fully to the CRDT model for all collaborative fields. Use the CRDT document as the source of truth, and persist snapshots to the database asynchronously via a background sync worker.
7. WebSocket Reconnection Blind Spots
Explanation: Network flakiness causes temporary disconnects. If the provider doesn't automatically request missing updates upon reconnection, the client falls out of sync.
Fix: Yjs providers handle this natively via sync messages. Ensure your server supports the Yjs sync protocol (y-websocket or y-redis for horizontal scaling). Monitor provider.on('status', ...) to display accurate connection states.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single-user editing with occasional saves | Traditional REST/GraphQL + optimistic UI | No concurrency requirements; simpler stack | Low infrastructure, low dev time |
| Real-time collaboration (<50 concurrent users) | Yjs + single WebSocket server | CRDT convergence guarantees zero data loss; minimal server logic | Moderate WebSocket hosting, low dev time |
| Enterprise scale (1000+ concurrent users) | Yjs + Redis-backed WebSocket cluster | Horizontal scaling via pub/sub; awareness sync across nodes | High infrastructure cost, moderate dev time |
| Offline-first mobile apps | Yjs + IndexedDB persistence + background sync | CRDTs merge offline edits seamlessly; no conflict resolution needed | Moderate storage overhead, high UX value |
| Strict audit/compliance requirements | CRDT + immutable operation log + periodic snapshots | Maintains convergence while satisfying regulatory history tracking | High storage cost, complex compliance pipeline |
Configuration Template
// config/yjs-provider.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
export function createCollaborationSession(docId: string, wsUrl: string) {
const doc = new Y.Doc({ gc: true });
// Local persistence for offline resilience
const persistence = new IndexeddbPersistence(docId, doc);
const provider = new WebsocketProvider(wsUrl, docId, doc, {
awareness: new Y.Awareness(doc),
connect: true,
});
// Sync local DB state to remote on connection
persistence.whenLoaded.then(() => {
const localState = Y.encodeStateAsUpdate(doc);
provider.awareness.setLocalStateField('user', { id: crypto.randomUUID() });
console.log('Local state synced to CRDT');
});
return { doc, provider, persistence };
}
Quick Start Guide
- Install dependencies:
npm install yjs y-websocket y-indexeddb
- Spin up a WebSocket server: Use the official
y-websocket reference server or deploy a Node.js instance with ws and y-websocket bindings.
- Create the custom hook: Copy the
useCollaborativeDocument hook into your project. Adjust the wsEndpoint to point to your server.
- Mount the component: Import
CollaborativeEditor and pass a unique documentId and WebSocket URL. Verify that typing in one browser tab instantly reflects in another.
- Add persistence: Integrate
y-indexeddb using the configuration template to enable offline editing and automatic state recovery on page reload.