Real-Time Collaboration: CRDTs, Operational Transforms, and Yjs in Production
Build real-time collaborative features — CRDTs vs operational transforms, Yjs implementation with WebSocket, presence indicators, conflict resolution, and produ
Real-Time Collaboration: CRDTs, Operational Transforms, and Yjs in Production
Google Docs-style collaborative editing is one of the hardest features to build correctly. Two users editing the same document at the same time creates conflicts — and resolving those conflicts correctly, at scale, across unreliable network connections, requires careful algorithm design.
This guide covers how collaborative editing actually works and how to implement it with the libraries that handle the hard parts.
The Core Problem: Concurrent Edits
When two users edit the same document simultaneously, you need a way to merge their changes without losing either edit or producing garbage output.
Initial state: "Hello World"
User A (offline): Inserts "Beautiful " after "Hello " → "Hello Beautiful World"
User B (offline): Deletes "World" → "Hello "
If you naively apply both changes:
- Apply A first, then B: "Hello Beautiful " (correct)
- Apply B first, then A: "Hello Beautiful" (wrong — B deleted from wrong position)
The challenge: deletions and insertions shift character positions. An operation valid when created may be wrong when applied to a state that changed since creation.
Operational Transforms vs CRDTs
| Approach | How It Works | Pros | Cons |
|---|---|---|---|
| Operational Transform (OT) | Transform each operation against all concurrent operations before applying | Battle-tested (Google Docs), minimal bandwidth | Requires central server to sequence operations; complex multi-user correctness |
| CRDT (Conflict-free Replicated Data Type) | Data structures designed so any merge order produces the same result | Works P2P, offline-first, no central sequencer | Higher memory usage per character; more complex implementation |
Practical guidance:
- Use CRDTs (specifically Yjs) for new projects — better offline support, simpler architecture, active ecosystem
- Use OT only if you're integrating with an existing OT-based system (Google Docs API, Quill's Delta format)
🌐 Looking for a Dev Team That Actually Delivers?
Most agencies sell you a project manager and assign juniors. Viprasol is different — senior engineers only, direct Slack access, and a 5.0★ Upwork record across 100+ projects.
- React, Next.js, Node.js, TypeScript — production-grade stack
- Fixed-price contracts — no surprise invoices
- Full source code ownership from day one
- 90-day post-launch support included
Yjs: The Production CRDT Library
Yjs is the most widely used CRDT library for JavaScript. It powers collaborative features in products like Vercel, Liveblocks, and Notion competitors.
Core types:
Y.Text— collaborative text with formatting supportY.Map— collaborative key-value storeY.Array— collaborative arrayY.XmlFragment— collaborative XML/HTML tree
Basic setup with WebSocket:
// server/collaboration.ts
import * as Y from 'yjs';
import { WebSocketServer } from 'ws';
import { setupWSConnection } from 'y-websocket/bin/utils';
const wss = new WebSocketServer({ port: 1234 });
// y-websocket handles:
// - Syncing Y.Doc state between clients
// - Broadcasting updates
// - Reconnection with state sync
wss.on('connection', (ws, req) => {
setupWSConnection(ws, req, {
gc: true, // Garbage collect deleted items to save memory
});
});
// client/collaboration.ts
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
// Each document has its own Y.Doc
const ydoc = new Y.Doc();
// Connect to WebSocket server — handles sync and reconnection
const provider = new WebsocketProvider(
'wss://your-collab-server.com',
'document-abc123', // Room/document ID
ydoc,
{ connect: true }
);
// Bind Y.Text to Quill editor
const ytext = ydoc.getText('content');
const editor = new Quill('#editor', { modules: { cursors: true } });
const binding = new QuillBinding(ytext, editor, provider.awareness);
// Connection state
provider.on('status', ({ status }: { status: string }) => {
console.log('Connection status:', status); // 'connecting' | 'connected' | 'disconnected'
});
Presence and Awareness
Presence (showing which users are online, where their cursors are, who's typing) is handled by Yjs's Awareness protocol — a separate layer from the document CRDT.
// Set local user's awareness state
provider.awareness.setLocalState({
user: {
id: currentUser.id,
name: currentUser.name,
color: '#' + Math.floor(Math.random() * 0xFFFFFF).toString(16), // Random color
avatar: currentUser.avatarUrl,
},
cursor: null, // Updated as user moves cursor
});
// Update cursor position as user types
editor.on('selection-change', (range) => {
if (range) {
provider.awareness.setLocalStateField('cursor', {
anchor: Y.createRelativePositionFromTypeIndex(ytext, range.index),
head: Y.createRelativePositionFromTypeIndex(ytext, range.index + range.length),
});
} else {
provider.awareness.setLocalStateField('cursor', null);
}
});
// React to other users' state changes
provider.awareness.on('change', () => {
const states = Array.from(provider.awareness.getStates().entries());
const otherUsers = states
.filter(([clientId]) => clientId !== ydoc.clientID)
.map(([, state]) => state.user);
updatePresenceUI(otherUsers);
});
React presence hook:
// hooks/usePresence.ts
import { useEffect, useState } from 'react';
import { WebsocketProvider } from 'y-websocket';
interface UserPresence {
id: string;
name: string;
color: string;
cursor?: { index: number; length: number } | null;
}
export function usePresence(provider: WebsocketProvider) {
const [users, setUsers] = useState<UserPresence[]>([]);
useEffect(() => {
const updateUsers = () => {
const states = Array.from(provider.awareness.getStates().entries());
const activeUsers = states
.filter(([clientId, state]) =>
clientId !== provider.awareness.clientID && state.user
)
.map(([, state]) => state.user as UserPresence);
setUsers(activeUsers);
};
provider.awareness.on('change', updateUsers);
updateUsers(); // Initial state
return () => {
provider.awareness.off('change', updateUsers);
};
}, [provider]);
return users;
}
// Usage in React component
function Editor() {
const activeUsers = usePresence(provider);
return (
<div>
{/* Presence avatars */}
<div className="flex -space-x-2">
{activeUsers.map(user => (
<div
key={user.id}
className="w-8 h-8 rounded-full border-2 border-white flex items-center justify-center text-xs font-medium text-white"
style={{ backgroundColor: user.color }}
title={user.name}
>
{user.name[0].toUpperCase()}
</div>
))}
</div>
<div id="editor" />
</div>
);
}
🚀 Senior Engineers. No Junior Handoffs. Ever.
You get the senior developer, not a project manager who relays your requirements to someone you never meet. Every Viprasol project has a senior lead from kickoff to launch.
- MVPs in 4–8 weeks, full platforms in 3–5 months
- Lighthouse 90+ performance scores standard
- Works across US, UK, AU timezones
- Free 30-min architecture review, no commitment
Shared State Beyond Text: Y.Map
For collaborative features beyond text editing (shared form state, diagram positions, kanban board):
// Collaborative kanban board with Y.Map
const ydoc = new Y.Doc();
const board = ydoc.getMap<{ title: string; status: string; assignee: string }>('kanban');
// Add a card (synced to all clients instantly)
board.set('card-001', {
title: 'Fix login bug',
status: 'in-progress',
assignee: 'user-123',
});
// Move a card
const card = board.get('card-001');
if (card) {
board.set('card-001', { ...card, status: 'done' });
}
// Observe all changes (including from remote clients)
board.observe((event) => {
event.changes.keys.forEach((change, key) => {
if (change.action === 'add') console.log('Card added:', key);
if (change.action === 'update') console.log('Card updated:', key);
if (change.action === 'delete') console.log('Card deleted:', key);
});
});
Persistence and Offline Support
Yjs documents can be persisted locally (IndexedDB) and on the server (Redis, PostgreSQL). Offline edits merge automatically when the user reconnects.
// client: persist to IndexedDB for offline support
import { IndexeddbPersistence } from 'y-indexeddb';
const ydoc = new Y.Doc();
const persistence = new IndexeddbPersistence('document-abc123', ydoc);
persistence.on('synced', () => {
console.log('Loaded from IndexedDB — offline edits are preserved');
});
// server: persist to Redis for reconnection sync
import { createClient } from 'redis';
import { RedisPersistence } from 'y-redis';
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const redisPersistence = new RedisPersistence({ redisClient });
wss.on('connection', (ws, req) => {
setupWSConnection(ws, req, {
persistence: redisPersistence,
gc: true,
});
});
Scaling the WebSocket Server
A single y-websocket server is a bottleneck. For production at scale:
Load Balancer
/ \
WebSocket Server 1 WebSocket Server 2
| |
└──────── Redis ────────┘
(pub/sub for
cross-server sync)
// Horizontal scaling with Redis pub/sub
// y-redis handles cross-server synchronization automatically
import { createYjsServer } from 'y-redis';
const server = await createYjsServer({
redisUrl: process.env.REDIS_URL!,
// Documents are sharded across servers via consistent hashing
// Redis pub/sub broadcasts updates between servers
});
For managed solutions, Liveblocks and PartyKit handle the infrastructure — you pay per monthly active user instead of running WebSocket servers.
| Solution | Cost | Scale | Control |
|---|---|---|---|
| Self-hosted y-websocket + Redis | $50–200/mo infra | Unlimited | Full |
| Liveblocks | $0–500/mo (usage-based) | Managed | High |
| PartyKit | $0–200/mo | Managed | High |
| Ably | $35–500/mo | Managed | Medium |
Working With Viprasol
We implement real-time collaboration features — from simple presence indicators to full Google Docs-style co-editing — using Yjs, WebSockets, and Redis. Our work includes both the frontend (cursor sync, presence UI, conflict-free state) and the backend (WebSocket server, persistence, horizontal scaling).
→ Talk to our team about adding collaborative features to your product.
See Also
- WebSocket and Event-Driven Architecture — real-time backend patterns
- Redis Use Cases — Redis pub/sub for cross-server sync
- Next.js Performance — real-time features in Next.js
- Feature Flags — rolling out collaboration features safely
- Web Development Services — real-time and collaborative product development
About the Author
Viprasol Tech Team
Custom Software Development Specialists
The Viprasol Tech team specialises in algorithmic trading software, AI agent systems, and SaaS development. With 100+ projects delivered across MT4/MT5 EAs, fintech platforms, and production AI systems, the team brings deep technical experience to every engagement. Based in India, serving clients globally.
Need a Modern Web Application?
From landing pages to complex SaaS platforms — we build it all with Next.js and React.
Free consultation • No commitment • Response within 24 hours
Need a custom web application built?
We build React and Next.js web applications with Lighthouse ≥90 scores, mobile-first design, and full source code ownership. Senior engineers only — from architecture through deployment.