Implementing Collaborative Editing on Website
Collaborative editing — synchronizing document state between multiple users in real time without conflicts. Problem trickier than "just WebSocket": two users editing same document simultaneously — whose change wins without losing either?
Two Approaches: OT vs CRDT
Operational Transformation (OT) — classic approach (Google Docs). Operations transform relative to competing operations. Requires central server for serialization.
CRDT (Conflict-free Replicated Data Types) — mathematical guarantee eventual consistency without coordinator. Yjs, Automerge — main implementations.
| OT | CRDT (Yjs) | |
|---|---|---|
| Central server | Required | Optional (P2P possible) |
| Offline editing | Complex | Built-in |
| Performance | High | High (Yjs very efficient) |
| Implementation complexity | High | Low (library handles) |
| Popular libs | ShareDB, ot.js | Yjs, Automerge |
For most new projects — Yjs choice.
Yjs: Architecture
Yjs provides shared types: Y.Text, Y.Map, Y.Array, Y.XmlFragment. Changes auto-sync via provider.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { QuillBinding } from 'y-quill';
import Quill from 'quill';
const ydoc = new Y.Doc();
const provider = new WebsocketProvider(
'wss://your-yjs-server.com',
'document-room-id',
ydoc,
{ connect: true }
);
const ytext = ydoc.getText('quill-content');
const editor = new Quill('#editor', { theme: 'snow' });
const binding = new QuillBinding(ytext, editor, provider.awareness);
provider.awareness.setLocalStateField('user', {
name: 'Ivan',
color: '#ff6b6b',
});
Sync Server
y-websocket — standard Yjs server. Can persist to Redis or LevelDB:
const { setupWSConnection } = require('y-websocket/bin/utils');
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer();
const wss = new WebSocket.Server({ server });
wss.on('connection', (conn, req) => {
setupWSConnection(conn, req, {
docName: getDocNameFromUrl(req.url),
gc: true,
});
});
server.listen(1234);
For production — Hocuspocus (official TipTap/Yjs server with auth, persistence, hooks):
import { Server } from '@hocuspocus/server';
import { Database } from '@hocuspocus/extension-database';
const server = Server.configure({
port: 1234,
extensions: [
new Database({
fetch: async ({ documentName }) => {
return await db.getDocument(documentName);
},
store: async ({ documentName, state }) => {
await db.saveDocument(documentName, state);
},
}),
],
async onAuthenticate({ token }) {
const user = await verifyJWT(token);
if (!user) throw new Error('Unauthorized');
return { user };
},
});
server.listen();
TipTap Integration
TipTap — most mature rich-text editor with native Yjs support via @tiptap/extension-collaboration:
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Collaboration from '@tiptap/extension-collaboration';
import CollaborationCursor from '@tiptap/extension-collaboration-cursor';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
const ydoc = new Y.Doc();
const provider = new HocuspocusProvider({
url: 'wss://your-server.com',
name: `document-${docId}`,
document: ydoc,
token: authToken,
});
const editor = new Editor({
extensions: [
StarterKit.configure({ history: false }),
Collaboration.configure({ document: ydoc }),
CollaborationCursor.configure({
provider,
user: { name: currentUser.name, color: generateColor(currentUser.id) },
}),
],
});
Cursors and Awareness
Awareness — ephemeral state (not saved): cursors, selections, online status.
provider.awareness.on('change', ({ added, updated, removed }) => {
const states = provider.awareness.getStates();
renderRemoteCursors(states);
});
editor.on('selectionUpdate', ({ editor }) => {
const { from, to } = editor.state.selection;
provider.awareness.setLocalStateField('cursor', {
anchor: Y.createRelativePositionFromTypeIndex(ytext, from),
head: Y.createRelativePositionFromTypeIndex(ytext, to),
});
});
Relative positions auto-adjust when content inserted.
Offline and Persistence
y-indexeddb saves to IndexedDB — users work offline, sync on reconnect:
import { IndexeddbPersistence } from 'y-indexeddb';
const persistence = new IndexeddbPersistence(`doc-${docId}`, ydoc);
persistence.on('synced', () => {
console.log('Local content loaded from IndexedDB');
});
// Auto-saves on each change, merges on reconnect
Version History
Yjs stores operation history. For UI "version history":
import { UndoManager } from 'yjs';
const undoManager = new UndoManager(ytext, {
captureTimeout: 500,
trackedOrigins: new Set([provider.awareness.clientID]),
});
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'z') undoManager.undo();
if (e.ctrlKey && e.key === 'y') undoManager.redo();
});
Snapshots (named versions):
const snapshot = Y.snapshot(ydoc);
const snapshotBinary = Y.encodeSnapshot(snapshot);
await saveSnapshot(docId, snapshotBinary);
const restoredDoc = Y.createDocFromSnapshot(ydoc, snapshot);
Access Control
Hocuspocus differentiates read/write:
async onAuthenticate({ token, documentName }) {
const user = await verifyToken(token);
const doc = await getDocumentMeta(documentName);
if (doc.ownerId === user.id) return { user, role: 'owner' };
if (doc.editors.includes(user.id)) return { user, role: 'editor' };
if (doc.viewers.includes(user.id)) return { user, role: 'viewer' };
throw new Error('Access denied');
},
async onChange({ context, update }) {
if (context.role === 'viewer') {
throw new Error('Read-only access');
}
}
Structured Content
Not just text. For forms, kanban, presentations:
const ytasks = ydoc.getArray('tasks');
const task = new Y.Map();
task.set('id', generateId());
task.set('title', 'New task');
task.set('status', 'todo');
ytasks.push([task]);
ytasks.observe(event => {
renderBoard(ytasks.toArray());
});
Scaling: Multi-Node
Hocuspocus supports Redis adapter for distributed Yjs:
import { Redis } from '@hocuspocus/extension-redis';
const server = Server.configure({
extensions: [
new Redis({
host: 'redis://your-redis:6379',
// All instances sync via Redis pub/sub
}),
],
});
Timeline
- Basic collaborative text editor (TipTap + Hocuspocus + PostgreSQL) — 5–7 days
- Remote cursors, presence indicators — plus 2–3 days
- Offline mode (IndexedDB persistence) — plus 1–2 days
- Version history with UI — plus 3–4 days
- Read/write access control — plus 2–3 days
- Structured content editing (board, forms) — separate estimate







