Architecture

Building a Real-time Collaborative Whiteboard with WebRTC and CRDTs

Master peer-to-peer web applications. A comprehensive tutorial to synchronizing vector drawings over WebRTC Data Channels using Loro CRDTs.

Sachin Sharma
Sachin SharmaCreator
May 31, 2026
6 min read
Building a Real-time Collaborative Whiteboard with WebRTC and CRDTs
Featured Resource
Quick Overview

Master peer-to-peer web applications. A comprehensive tutorial to synchronizing vector drawings over WebRTC Data Channels using Loro CRDTs.

Building a Real-time Collaborative Whiteboard with WebRTC and CRDTs

Real-time collaborative whiteboards (like Miro, Figma, or Excalidraw) are incredibly engaging digital products. They allow distributed teams to brainstorm, sketch, and map out architectures together in real time.

However, from a backend perspective, syncing thousands of high-frequency mouse-drag coordinate movements and vector strokes through a centralized cloud server (like a standard WebSocket server) is highly inefficient:

  1. 2.
    High Server Costs: The server is constantly forced to receive, serialize, and broadcast hundreds of messages per second to every single active client.
  2. 4.
    Increased Latency: Pushing messages from Client A through a central server in Virginia to reach Client B who is sitting in the same office in Delhi introduces a 200ms roundtrip delay, destroying the fluid, real-time feel of drawing.

What if clients could connect directly to each other peer-to-peer (P2P), exchanging draw strokes in less than 10ms with zero server transit overhead?

By combining WebRTC Data Channels (for low-latency, direct peer-to-peer communication) and CRDTs (Conflict-free Replicated Data Types) (to handle state merging and offline synchronization mathematically), we can build an incredibly responsive collaborative whiteboard that scales infinitely at zero server hosting cost.

In this guide, we'll build a P2P collaborative whiteboard sync engine using WebRTC and Loro CRDT.


⚡ 1. The P2P Mesh Architecture

Unlike centralized client-server models, a WebRTC P2P whiteboard connects client browser sandboxes directly to each other:

  [Client A Browser] <═════(WebRTC P2P Data Channel)═════> [Client B Browser]
          │ (Draw Coordinates / CRDT Delta)                 │ (Draw Coordinates / CRDT Delta)
          ▼                                                  ▼
[Local Whiteboard Canvas]                                 [Local Whiteboard Canvas]

To establish this direct peer connection, we still need a tiny, lightweight Signaling Broker (via a simple HTTP or WebSocket server) during initial setup. The signaling broker allows peers to find each other, exchange network metadata (ICE candidates), and coordinate a cryptographic handshake. Once the direct P2P connection is established, the signaling server is bypassed completely.


🏗️ 2. Step 1: Establishing the WebRTC P2P Data Channel

Let's look at the implementation required to spin up the P2P connection and initialize a bidirectional WebRTC Data Channel:

typescript
class PeerConnectionManager { private peerConnection: RTCPeerConnection; private dataChannel: RTCDataChannel | null = null; private onMessageCallback: (data: Uint8Array) => void; constructor(iceServers: RTCConfiguration, onMessage: (data: Uint8Array) => void) { this.peerConnection = new RTCPeerConnection(iceServers); this.onMessageCallback = onMessage; this.setupIceListeners(); } // 1. Peer A (Initiator) creates the Data Channel public async createOffer(): Promise<RTCSessionDescriptionInit> { this.dataChannel = this.peerConnection.createDataChannel("whiteboard-sync", { ordered: true, // Ensure vector strokes arrive in correct chronological order }); this.bindDataChannelEvents(this.dataChannel); const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); return offer; } // 2. Peer B (Receiver) intercepts the incoming Data Channel public async handleOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> { await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); this.peerConnection.ondatachannel = (event) => { this.dataChannel = event.channel; this.bindDataChannelEvents(this.dataChannel); }; const answer = await this.peerConnection.createAnswer(); await this.peerConnection.setLocalDescription(answer); return answer; } public async handleAnswer(answer: RTCSessionDescriptionInit) { await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); } private bindDataChannelEvents(channel: RTCDataChannel) { channel.binaryType = "arraybuffer"; channel.onmessage = (event: MessageEvent) => { const buffer = new Uint8Array(event.data); // Forward the binary CRDT delta directly to the whiteboard merge engine this.onMessageCallback(buffer); }; channel.onopen = () => console.log("WebRTC P2P Data Channel open and active!"); channel.onclose = () => console.log("WebRTC P2P Data Channel closed."); } public broadcastUpdate(bytes: Uint8Array) { if (this.dataChannel && this.dataChannel.readyState === "open") { // Send binary CRDT delta directly to peer over P2P Data Channel this.dataChannel.send(bytes); } } private setupIceListeners() { this.peerConnection.onicecandidate = (event) => { if (event.candidate) { // Broadcast local ICE candidate network metadata to signaling broker sendCandidateToSignalingServer(event.candidate); } }; } } // Placeholder for signaling helper function sendCandidateToSignalingServer(candidate: RTCIceCandidate) {}

🛠️ 3. Step 2: Synchronizing the Whiteboard Vector Strokes via Loro CRDT

Because whiteboards are highly dynamic, we need to store drawing strokes mathematically so peers can merge their vector lists without overwriting each other.

We represent each vector line as a Loro Map containing a unique ID, color, thickness, and a Loro List of points [x, y].

Here is the whiteboard controller implementation:

typescript
import { Loro, LoroList, LoroMap } from "loro-crdt"; class CollaborativeWhiteboard { private doc: Loro; private peerManager: PeerConnectionManager; private canvas: HTMLCanvasElement; private ctx: CanvasRenderingContext2D; private isDrawing = false; private currentStrokeId: string | null = null; constructor(canvas: HTMLCanvasElement, iceConfig: RTCConfiguration) { this.canvas = canvas; this.ctx = canvas.getContext("2d")!; this.doc = new Loro(); // Initialize WebRTC P2P Connection Manager this.peerManager = new PeerConnectionManager(iceConfig, (remoteBytes) => { this.handleRemoteMerge(remoteBytes); }); this.setupCanvasListeners(); } private handleRemoteMerge(bytes: Uint8Array) { // 1. Merge incoming P2P binary updates directly this.doc.importUpdate(bytes); // 2. Redraw canvas completely matching the converged CRDT state this.redrawCanvas(); } private setupCanvasListeners() { const strokes = this.doc.getMap("whiteboard-strokes"); this.canvas.addEventListener("mousedown", (e) => { this.isDrawing = true; this.currentStrokeId = `stroke-${Date.now()}-${Math.random().toString(36).substr(2, 5)}`; // 1. Create a new stroke inside our Loro CRDT map this.doc.transact(() => { const strokeMap = strokes.setContainer(this.currentStrokeId!, new LoroMap()); strokeMap.set("color", "#6366f1"); strokeMap.set("width", 3); // Initialize points array container strokeMap.setContainer("points", new LoroList()); }); }); this.canvas.addEventListener("mousemove", (e) => { if (!this.isDrawing || !this.currentStrokeId) return; const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // 2. Push new drawing coordinate into Loro points list this.doc.transact(() => { const strokeMap = strokes.get(this.currentStrokeId!) as LoroMap; const pointsList = strokeMap.get("points") as LoroList; pointsList.insert(pointsList.length, `${x},${y}`); }); // 3. Export only the local changes const updateBytes = this.doc.exportUpdate(); // 4. Broadcast changes P2P immediately! this.peerManager.broadcastUpdate(updateBytes); this.redrawCanvas(); }); this.canvas.addEventListener("mouseup", () => { this.isDrawing = false; this.currentStrokeId = null; }); } private redrawCanvas() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); const strokes = this.doc.getMap("whiteboard-strokes"); // Loop through all strokes in our Loro CRDT and render them for (const key of Object.keys(strokes.value)) { const strokeMap = strokes.get(key) as LoroMap; const color = strokeMap.get("color") as string; const width = strokeMap.get("width") as number; const pointsList = strokeMap.get("points") as LoroList; if (pointsList.length < 2) continue; this.ctx.beginPath(); this.ctx.strokeStyle = color; this.ctx.lineWidth = width; this.ctx.lineCap = "round"; const firstPoint = (pointsList.get(0) as string).split(","); this.ctx.moveTo(parseFloat(firstPoint[0]), parseFloat(firstPoint[1])); for (let i = 1; i < pointsList.length; i++) { const point = (pointsList.get(i) as string).split(","); this.ctx.lineTo(parseFloat(point[0]), parseFloat(point[1])); } this.ctx.stroke(); } } }

🏁 3. Conclusion: Decoupled, Infinite Scale P2P Applications

By leveraging WebRTC Data Channels to handle high-frequency communication P2P and utilizing Loro CRDTs to resolve concurrently drawn vector strokes mathematically, you eliminate server bottleneck completely. Your whiteboard coordinates exchange in less than 10ms, providing a highly snappy, fluid drawing experience that scales to thousands of concurrent users at absolute zero server hosting costs.

Sachin Sharma

Sachin Sharma

Software Developer

Building digital experiences at the intersection of design and code. Sharing weekly insights on engineering, productivity, and the future of tech.