ByteGuideHub
Web Development & Collaboration • • 9 min read

Self-Hosted Real-Time Collaborative Document Editing with CRDTs and WebRTC: A Step-by-Step Guide

Learn to build a self-hosted collaborative document editor using CRDTs and WebRTC for peer-to-peer synchronization. Step-by-step guide with code examples and comparison tables.

Sponsored / Ad Space

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:

  1. Signaling — exchange SDP offers/answers and ICE candidates (you host this).
  2. STUN — discover public IPs for NAT traversal (public servers exist).
  3. 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

LibraryText CRDTBundle SizeEcosystemBest For
YjsYATA~40 KB gzipRich (Tiptap, ProseMirror, CodeMirror)Most web apps
AutomergeRGA-like~120 KB gzipJSON-firstStructured data, offline-first apps
LoroFugue~60 KB gzipGrowingPerformance-critical, Rust/WASM
Diamond TypesRange-based~30 KB gzipMinimalResearch, 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

ApproachComplexityScalabilitySelf-Host EffortNotes
Custom WebSocketLowHighLowFull control; ~50 LOC
y-webrtc-signalingVery lowMediumTrivialShips with Yjs ecosystem
Matrix/WebRTCHighHighHighOverkill for small teams
PeerJS ServerLowMediumLowGood docs, extra dependency
Firebase/AblyVery lowHighNone (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-webrtc logs: Set localStorage.debug = 'y-webrtc*' for verbose signaling logs.
  • webrtc-internals: chrome://webrtc-internals shows live data channel stats, ICE state, and packet loss.
  • Unit tests: Use Y.applyUpdate in 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-secret and 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.

Sponsored / Ad Space