Self-Hosted Real-Time Collaborative Document Editing with CRDTs and WebRTC
Quick Summary (TL;DR) Build a peer-to-peer collaborative editor by combining CRDTs (conflict-free replicated data types) for deterministic merging with WebRTC data channels for direct peer sync. You only need a lightweight signaling server (WebSocket) and optionally a TURN server. This guide walks through project setup, CRDT implementation, WebRTC connection flow, editor UI, and persistence — all self-hostable on a single VPS.
Introduction: Why Self-Hosted Collaborative Editing?
Cloud editors like Google Docs are convenient, but they come with trade-offs: your data lives on someone else’s infrastructure, pricing scales per seat, and you can’t audit the sync logic. Self-hosting flips the model. You own the data, control the encryption, and pay only for your own server.
The traditional path to collaborative editing — Operational Transformation (OT) — requires a central server to transform and rebroadcast every operation. That’s a bottleneck and a single point of failure. CRDTs + WebRTC sidesteps this entirely: each peer holds a full replica, merges edits deterministically, and syncs directly with other peers. The server only brokers the initial handshake.
Understanding the Core Technologies
Conflict-Free Replicated Data Types (CRDTs)
A CRDT is a data structure whose replicas can be merged without coordination and always converge to the same state. For text, sequence CRDTs (RGA, Logoot, Yjs’s YATA) assign each character a unique identifier and define a total order over insertions. When two users type at the same position, the merge is deterministic — no conflict resolution dialog, no lost edits.
Key properties:
- Commutative: order of operation application doesn’t matter.
- Associative: grouping doesn’t matter.
- Idempotent: applying the same op twice changes nothing.
WebRTC for Peer-to-Peer Communication
WebRTC provides encrypted, low-latency data channels between browsers. It needs three things:
- Signaling — exchange SDP offers/answers and ICE candidates (you host this).
- STUN — discover public IPs for NAT traversal (public servers exist).
- TURN — relay when direct P2P fails (self-host with Coturn).
Once connected, data flows peer-to-peer over DTLS-encrypted channels.
Architecture Overview
flowchart TD
A[Peer A - Browser] -->|WebSocket| S[Signaling Server]
B[Peer B - Browser] -->|WebSocket| S
C[Peer C - Browser] -->|WebSocket| S
S -->|SDP + ICE relay| A
S -->|SDP + ICE relay| B
S -->|SDP + ICE relay| C
A <-->|WebRTC DataChannel + CRDT ops| B
B <-->|WebRTC DataChannel + CRDT ops| C
A <-->|WebRTC DataChannel + CRDT ops| C
A --> IDB[(IndexedDB)]
B --> IDB
C --> IDB
The signaling server is stateless after handshake. Each peer persists its CRDT state locally.
Step-by-Step Implementation
Step 1: Set Up the Project Structure
We’ll use Yjs (mature CRDT) and y-webrtc (WebRTC provider), plus a tiny signaling server.
mkdir collab-editor && cd collab-editor
npm init -y
npm install yjs y-webrtc y-indexeddb
npm install --save-dev vite
Project layout:
collab-editor/
├── index.html
├── src/
│ ├── main.js
│ ├── editor.js
│ └── signaling.js
└── server/
└── signal-server.js
Step 2: Implement the CRDT Data Structure
Yjs gives us a shared text type. Every peer instantiates the same document name and merges automatically.
// src/editor.js
import * as Y from 'yjs';
export function createDoc(roomName) {
const doc = new Y.Doc();
const ytext = doc.getText('content');
return { doc, ytext };
}
The ytext object exposes insert, delete, and observe. Yjs handles identifier assignment and ordering internally — you never touch the merge logic.
Step 3: Establish WebRTC Connections
Use y-webrtc to abstract signaling. It connects to a WebSocket signaling server and handles offer/answer exchange.
// src/signaling.js
import { WebrtcProvider } from 'y-webrtc';
export function connectPeers(doc, roomName) {
const provider = new WebrtcProvider(roomName, doc, {
signaling: ['wss://your-domain.com:4444'],
maxConns: 20,
filterBcConns: true,
});
return provider;
}
The signaling server itself is trivial — a WebSocket relay that broadcasts messages to peers in the same room. y-webrtc ships one you can run directly:
npx y-webrtc-signaling --port 4444
For production, terminate TLS with Nginx and expose wss://.
Step 4: Integrate CRDT with WebRTC Data Channels
y-webrtc uses WebRTC data channels under the hood. Each update to ytext triggers a doc.on('update') event, which the provider serializes and broadcasts to connected peers. Incoming updates call Y.applyUpdate on the local doc.
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
const doc = new Y.Doc();
const provider = new WebrtcProvider('team-doc-42', doc);
provider.on('synced', (state) => {
console.log('Synced with peers:', state);
});
// Send a local edit
const ytext = doc.getText('content');
ytext.insert(0, 'Hello, world!');
// Listen for remote edits
ytext.observe(event => {
renderEditor(ytext.toString());
});
Step 5: Build the Editor UI
Use a plain <textarea> or a rich editor like ProseMirror/Tiptap bound to ytext. Here’s a minimal sync loop:
const textarea = document.getElementById('editor');
let applyingRemote = false;
ytext.observe(() => {
applyingRemote = true;
const caret = textarea.selectionStart;
textarea.value = ytext.toString();
textarea.setSelectionRange(caret, caret);
applyingRemote = false;
});
textarea.addEventListener('input', () => {
if (applyingRemote) return;
const current = ytext.toString();
const next = textarea.value;
// Naive diff — use Yjs relative positions in production
if (current !== next) {
ytext.delete(0, current.length);
ytext.insert(0, next);
}
});
For production, use y-textarea or Tiptap’s Yjs binding to preserve cursor positions correctly.
Step 6: Handle Persistence and Offline Support
Bind IndexedDB so edits survive reloads and offline sessions merge on reconnect.
import { IndexeddbPersistence } from 'y-indexeddb';
const persistence = new IndexeddbPersistence('team-doc-42', doc);
persistence.on('synced', () => console.log('Loaded from IndexedDB'));
When a peer reconnects, Yjs computes the diff between local and remote state and exchanges only missing updates — efficient even after long offline periods.
Comparison of CRDT Libraries
| Library | Text CRDT | Bundle Size | Ecosystem | Best For |
|---|---|---|---|---|
| Yjs | YATA | ~40 KB gzip | Rich (Tiptap, ProseMirror, CodeMirror) | Most web apps |
| Automerge | RGA-like | ~120 KB gzip | JSON-first | Structured data, offline-first apps |
| Loro | Fugue | ~60 KB gzip | Growing | Performance-critical, Rust/WASM |
| Diamond Types | Range-based | ~30 KB gzip | Minimal | Research, custom integrations |
Yjs wins for editor integrations; Automerge is better when your document is a rich JSON tree rather than plain text.
Comparison of WebRTC Signaling Approaches
| Approach | Complexity | Scalability | Self-Host Effort | Notes |
|---|---|---|---|---|
| Custom WebSocket | Low | High | Low | Full control; ~50 LOC |
| y-webrtc-signaling | Very low | Medium | Trivial | Ships with Yjs ecosystem |
| Matrix/WebRTC | High | High | High | Overkill for small teams |
| PeerJS Server | Low | Medium | Low | Good docs, extra dependency |
| Firebase/Ably | Very low | High | None (third-party) | Breaks self-hosted goal |
For a self-hosted small-team editor, y-webrtc-signaling behind Nginx is the sweet spot.
Testing and Debugging
- Two-tab test: Open the same room in two browser tabs; type in one, verify the other updates.
- Network throttling: Use Chrome DevTools to simulate latency and confirm CRDT convergence.
y-webrtclogs: SetlocalStorage.debug = 'y-webrtc*'for verbose signaling logs.webrtc-internals:chrome://webrtc-internalsshows live data channel stats, ICE state, and packet loss.- Unit tests: Use
Y.applyUpdatein Node to simulate concurrent edits without a browser.
Deployment Considerations
- Signaling server: Run behind Nginx with
wss://. A 1 vCPU / 1 GB VPS handles hundreds of concurrent rooms. - TURN server: Deploy Coturn for peers behind symmetric NATs. Configure with
use-auth-secretand short-lived credentials. - Static hosting: Serve the frontend from the same VPS or a CDN (Cloudflare Pages, Netlify).
- Backups: Since each peer has a full replica, backup is trivial — dump
Y.encodeStateAsUpdate(doc)to object storage periodically. - Security: Enforce room-level auth tokens in the signaling handshake, validate incoming CRDT updates (Yjs has size limits), and rotate TURN credentials.
sequenceDiagram
participant A as Peer A
participant S as Signaling Server
participant B as Peer B
A->>S: join(room-42)
B->>S: join(room-42)
A->>S: offer(SDP)
S->>B: relay offer
B->>S: answer(SDP)
S->>A: relay answer
A->>B: ICE candidates
B->>A: ICE candidates
A<<->>B: WebRTC DataChannel (CRDT ops)
Conclusion
Self-hosting a collaborative editor with CRDTs and WebRTC is more approachable than it sounds. Yjs handles the hard math of conflict-free merging, y-webrtc handles peer discovery, and a ~50-line signaling server is the only backend you truly need. The result is a private, low-latency editor where your team’s data never leaves infrastructure you control. Start with a two-tab prototype, add IndexedDB persistence, then layer in TURN and auth as you scale.
FAQ
What are the advantages of using CRDTs over Operational Transformation (OT) for collaborative editing?
CRDTs are conflict-free by design and merge deterministically without a central server, making them ideal for peer-to-peer architectures. OT requires a central server to transform operations, introducing a single point of failure and bottleneck. CRDTs also handle offline editing and eventual consistency more gracefully — each replica applies operations independently and merges later without conflicts.
Do I need a server for WebRTC?
Yes, but only for signaling. WebRTC requires a signaling server to exchange SDP offers/answers and ICE candidates. Once the connection is established, data flows directly between peers. You can run a lightweight WebSocket signaling server yourself, and optionally self-host STUN/TURN (e.g., with Coturn) for NAT traversal.
How do CRDTs handle concurrent edits?
CRDTs guarantee all replicas converge to the same state regardless of operation order. This relies on operations being commutative, associative, and idempotent. For text, sequence CRDTs (RGA, Logoot, YATA) assign unique identifiers to each character and use them to determine the correct order when merging. Concurrent inserts at the same position are resolved deterministically by identifier comparison.
Can I use WebRTC for large groups?
WebRTC mesh networks scale poorly: each participant connects to every other, producing n×(n−1)/2 connections. This works well up to ~10–15 participants. For larger groups, you need an SFU or relay server, which reintroduces a central component. For collaborative editing in small teams, a mesh is usually sufficient.
How do I ensure data persistence in a peer-to-peer collaborative editor?
Have each peer persist the CRDT state locally — IndexedDB in the browser, or a file/DB dump on a persistent node. Since CRDTs are eventually consistent, any peer can reconstruct the document from its local state. New peers receive the current state from any existing peer, and periodic encodeStateAsUpdate backups provide durability.