Self-Hosted Real-Time Collaborative Whiteboard with WebRTC and CRDTs
Quick Summary (TL;DR) Build a low-latency, self-hosted collaborative whiteboard by combining WebRTC data channels (peer-to-peer transport) with CRDTs (conflict-free replicated data types). WebRTC eliminates server relay latency; CRDTs guarantee that every peer converges to the same drawing state without a central authority. You’ll need a lightweight signaling server (Node.js + WebSocket), a CRDT library like Yjs, and optionally a self-hosted Coturn TURN server for NAT traversal. Expect ~200 lines of core code and a fully offline-capable, privacy-preserving whiteboard.
Real-time collaboration tools have become table stakes for distributed teams, but most commercial whiteboards route every keystroke and cursor movement through a central server. That architecture is simple, but it’s also a latency tax and a privacy liability. In this guide, we’ll build a self-hosted collaborative whiteboard that uses WebRTC for peer-to-peer transport and CRDTs for conflict-free state synchronization — giving you sub-50ms latency, no central data store, and full control over your infrastructure.
Introduction: Why WebRTC and CRDTs?
Traditional collaborative apps rely on a central server that receives, orders, and rebroadcasts every update. This works, but it introduces three problems: latency (every action round-trips through the server), cost (bandwidth scales with users), and privacy (the server sees everything).
WebRTC solves the transport problem by connecting browsers directly. CRDTs solve the consistency problem: instead of requiring a server to order events, each peer merges updates deterministically. Together, they let you build a whiteboard where data flows peer-to-peer and every replica converges — no matter how updates arrive.
Architecture Overview
flowchart LR
A[Peer A<br/>Browser] <-->|WebRTC DataChannel| B[Peer B<br/>Browser]
A <-->|WebRTC DataChannel| C[Peer C<br/>Browser]
B <-->|WebRTC DataChannel| C
A -.->|SDP + ICE| S[Signaling Server<br/>Node.js + WS]
B -.->|SDP + ICE| S
C -.->|SDP + ICE| S
S -.->|Room Registry| DB[(Redis / In-Memory)]
A -.->|NAT Fallback| T[Coturn TURN/STUN]
B -.->|NAT Fallback| T
WebRTC for Peer-to-Peer Transport
WebRTC’s RTCDataChannel provides an ordered or unordered, reliable or unreliable channel between two browsers. For a whiteboard, we use ordered + reliable channels for state sync, and optionally unordered + unreliable channels for cursor positions where dropping a frame is fine.
CRDTs for Conflict-Free State
A CRDT is a data structure that can be replicated across peers and merged without coordination. For whiteboards, each drawing element (stroke, shape, text) becomes a CRDT object with a unique ID, a logical timestamp, and a tombstone flag for deletion. Concurrent edits merge automatically.
Signaling Server
WebRTC can’t bootstrap itself — peers need to exchange SDP offers/answers and ICE candidates. A tiny signaling server handles this handshake, then gets out of the way. It never touches your drawing data.
Prerequisites and Tools
- Node.js 18+ and npm
- Yjs (
yjs,y-webrtc) — high-performance CRDT library - ws — lightweight WebSocket server for signaling
- Coturn — optional TURN/STUN server for NAT traversal
- A modern browser with WebRTC support (all evergreen browsers)
Step 1: Setting Up the Signaling Server
The signaling server relays SDP and ICE messages between peers in the same room. It’s stateless beyond a room registry.
// server.js
import { WebSocketServer } from 'ws';
const rooms = new Map(); // roomId -> Set<ws>
const wss = new WebSocketServer({ port: 4444 });
wss.on('connection', (ws, req) => {
const roomId = new URL(req.url, 'http://x').searchParams.get('room');
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId).add(ws);
ws.on('message', (data) => {
// Blindly relay to every other peer in the room
for (const peer of rooms.get(roomId)) {
if (peer !== ws && peer.readyState === 1) peer.send(data);
}
});
ws.on('close', () => {
rooms.get(roomId)?.delete(ws);
if (rooms.get(roomId)?.size === 0) rooms.delete(roomId);
});
});
Run it with node server.js. Notice there’s no persistence — signaling is ephemeral by design.
Step 2: Building the Whiteboard Frontend
Canvas Setup and Drawing Logic
We’ll use a single <canvas> element. Each stroke is captured as a list of points, then wrapped in a CRDT map.
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
let currentStroke = null;
canvas.addEventListener('pointerdown', (e) => {
currentStroke = { id: crypto.randomUUID(), points: [], color: '#222' };
currentStroke.points.push([e.offsetX, e.offsetY]);
});
canvas.addEventListener('pointermove', (e) => {
if (!currentStroke) return;
currentStroke.points.push([e.offsetX, e.offsetY]);
redraw(); // local immediate feedback
});
canvas.addEventListener('pointerup', () => {
if (currentStroke) {
strokes.set(currentStroke.id, currentStroke); // CRDT write
currentStroke = null;
}
});
Integrating a CRDT Library
Yjs gives us a Y.Map that syncs over any transport. Each stroke is a key; the value is a plain object.
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
const doc = new Y.Doc();
const strokes = doc.getMap('strokes');
const provider = new WebrtcProvider('whiteboard-room-42', doc, {
signaling: ['wss://your-signaling-host'],
});
// Re-render whenever the CRDT changes (from any peer)
strokes.observe(() => redraw());
function redraw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (const stroke of strokes.values()) {
ctx.beginPath();
stroke.points.forEach(([x, y], i) =>
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)
);
ctx.strokeStyle = stroke.color;
ctx.stroke();
}
}
That’s it — Yjs handles the WebRTC connection, CRDT merging, and even reconnection logic under the hood. The rest of this guide explains what’s happening so you can customize it.
Step 3: Establishing WebRTC Connections
If you use y-webrtc, connection management is abstracted. But understanding the handshake matters when debugging.
Creating Offers and Answers
sequenceDiagram
participant A as Peer A (Initiator)
participant S as Signaling Server
participant B as Peer B
A->>A: createOffer()
A->>S: SDP Offer
S->>B: Relay SDP Offer
B->>B: createAnswer()
B->>S: SDP Answer
S->>A: Relay SDP Answer
A->>S: ICE Candidate
S->>B: ICE Candidate
B->>S: ICE Candidate
S->>A: ICE Candidate
A<<->>B: DataChannel Open (P2P)
Handling ICE Candidates
ICE candidates describe network paths (host, srflx, relay). Trickle ICE sends them as they’re discovered, speeding up connection setup. In Yjs this is automatic; in raw WebRTC you’d listen to onicecandidate and forward each candidate through your signaling channel.
Step 4: Synchronizing State with CRDTs
Data Model for Whiteboard Elements
| Field | Type | Purpose |
|---|---|---|
id | UUID | Unique CRDT key, never reused |
points | [x, y][] | Polyline coordinates |
color | string | Stroke color |
author | string | Peer ID (for cursor attribution) |
deleted | boolean | Tombstone for undo/redo |
ts | number | Lamport-style logical clock |
Merging Updates and Handling Conflicts
Yjs uses a YATA (Yet Another Transformation Approach) algorithm. When two peers draw simultaneously, each stroke gets a unique ID and is inserted into the shared map. There’s no “last write wins” ambiguity — both strokes coexist. Deletions are tombstones, so a delete on one peer always wins over a concurrent edit.
function deleteStroke(id) {
const s = strokes.get(id);
if (s) strokes.set(id, { ...s, deleted: true });
}
// In redraw(): skip strokes where `deleted === true`
Step 5: Offline-First and Persistence
Because CRDTs are commutative, a peer can draw offline and sync later. Yjs ships with y-indexeddb:
import { IndexeddbPersistence } from 'y-indexeddb';
new IndexeddbPersistence('whiteboard-room-42', doc);
Now the board survives refreshes and works on a plane. When the peer reconnects, Yjs exchanges a state vector and transmits only missing updates — often just a few hundred bytes.
Step 6: Scaling and Multi-User Support
A pure P2P mesh creates O(n²) connections. Practical limits:
| Users | Topology | Notes |
|---|---|---|
| 2–6 | Full mesh | Ideal, lowest latency |
| 7–15 | Mesh + TURN | Works, watch CPU |
| 16–50 | Star (super-peer) | One peer relays |
| 50+ | SFU (mediasoup) | Server-mediated, still low latency |
For larger rooms, run a Yjs WebSocket server (y-websocket) as a hub. It’s still CRDT-based, so no conflict logic is needed — the server is just another replica.
Comparison: WebRTC vs. WebSocket for Whiteboards
| Criterion | WebRTC (P2P) | WebSocket (Server) |
|---|---|---|
| Latency | ~10–50ms | ~50–150ms |
| Server bandwidth | Near zero | Scales with users |
| NAT traversal | Requires STUN/TURN | Not needed |
| Setup complexity | High | Low |
| Max practical users | ~15 (mesh) | Hundreds |
| Privacy | Data never hits server | Server sees all |
| Offline support | Native with CRDTs | Requires CRDTs anyway |
Verdict: Use WebRTC for small, latency-sensitive, privacy-critical rooms. Fall back to WebSocket + Yjs for large audiences.
Testing and Debugging
chrome://webrtc-internals— inspect ICE state, bitrate, packet loss.- Trickle ICE tester — verify your STUN/TURN config from outside your network.
- Yjs DevTools — visualize document structure and update sizes.
- Network throttling — simulate 200ms latency and packet loss to validate CRDT convergence.
A quick convergence test: open two tabs, draw simultaneously with the network throttled, then reconnect. Both tabs should show identical strokes.
Deployment Considerations
- Serve over HTTPS. WebRTC requires a secure context.
- Self-host Coturn for TURN relay. Use time-limited credentials (
use-auth-secret). - Containerize the signaling server and TURN server with Docker Compose.
- Reverse proxy signaling through Nginx with WebSocket upgrade headers.
- Monitor ICE failure rates — they reveal TURN misconfigurations.
- Authenticate rooms with JWT so only authorized users join.
location /signal {
proxy_pass http://signaling:4444;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
Conclusion
Combining WebRTC and CRDTs gives you a collaborative whiteboard that is fast, private, and resilient. WebRTC removes the server from the data path; CRDTs remove the need for a coordinator. The result is a system that works offline, scales gracefully to small teams, and hands you full control over your data. Start with y-webrtc for a 30-minute prototype, then layer in TURN, persistence, and authentication as your needs grow.
Frequently Asked Questions
What are the advantages of using WebRTC over WebSockets for a collaborative whiteboard? WebRTC offers lower latency because data travels directly between peers (P2P) without a central server relay. It also reduces server bandwidth costs and improves privacy. However, WebRTC requires a signaling server and may face NAT traversal issues, while WebSockets are simpler to set up and work well for smaller groups.
How do CRDTs handle conflicts in a collaborative whiteboard? CRDTs automatically merge concurrent updates without conflicts by using mathematical properties like commutativity and idempotence. Each drawing element (e.g., a line) is represented as a CRDT object with a unique ID and timestamp. When peers receive updates, they merge them deterministically, ensuring all replicas converge to the same state.
Can I use WebRTC without a server? No, WebRTC requires a signaling server to exchange session descriptions (SDP) and ICE candidates. However, once the connection is established, data flows directly between peers. For NAT traversal, you may also need STUN/TURN servers, which can be self-hosted (e.g., Coturn).
What CRDT libraries are recommended for JavaScript?
Popular options include Yjs, Automerge, and ShareDB. Yjs is highly performant and integrates well with WebRTC via y-webrtc. Automerge is robust for complex data but has higher overhead. For whiteboards, Yjs is often preferred due to its efficiency and active community.
How does offline-first work with CRDTs and WebRTC? CRDTs allow local changes to be made offline. When the peer reconnects, it syncs with others by exchanging CRDT updates. WebRTC’s data channels resume communication, and the CRDT merge algorithm ensures all changes are integrated without loss. For persistence, store the CRDT state in IndexedDB or localStorage.
What are the scalability limits of a P2P whiteboard? P2P mesh networks scale poorly with many participants because each peer must connect to every other peer, leading to O(n²) connections. For large groups (e.g., >10 users), a hybrid approach using a central server (SFU) or a star topology with a super-peer is recommended. WebRTC with an SFU like mediasoup can handle larger audiences.
How do I secure a self-hosted WebRTC whiteboard? Use HTTPS for the signaling server, enable DTLS encryption (default in WebRTC), and implement authentication (e.g., JWT) for room access. For TURN servers, use time-limited credentials. Regularly update dependencies and consider end-to-end encryption for sensitive data, though WebRTC already encrypts media and data in transit.