Building a Real-Time Collaborative Canvas with WebSockets and Yjs
Shanawar Ali
Building a Real-Time Collaborative Canvas with WebSockets and Yjs
Direct Answer: To build a real-time collaborative canvas, pair an HTML5 Canvas front end with Yjs—a Conflict-free Replicated Data Type (CRDT) library—and a WebSocket transport layer such as y-websocket. Yjs resolves drawing state conflicts locally across client devices without requiring a complex central server, ensuring smooth state convergence even during high network latency or offline usage.
Why Traditional WebSockets Fail for Real-Time Drawing
Building a multi-user whiteboard by naively broadcasting mouse positions or raw canvas updates over WebSockets quickly leads to state drift. Out-of-order network packets, packet loss, and latency differences cause drawing strokes to collapse, overwrite, or render out of order across client screens.
To keep distributed user states synchronized, collaborative tools rely on real-time data frameworks. The two primary paradigms for this are Operational Transformation (OT) and Conflict-free Replicated Data Types (CRDTs).
| Feature | Operational Transformation (OT) | CRDTs (Yjs) |
|---|---|---|
| Server Requirement | Requires central, stateful server to sequence operations | Decentralized; server acts as a simple relay |
| Offline Capability | Complex to reconcile offline edits | Native offline support and automatic sync |
| Conflict Resolution | Transformed through server logic | Deterministic merge based on data structure algorithms |
| Primary Use Case | Google Docs, centralized text editors | Figma-like canvases, offline-first apps, P2P text |
Structuring the Shared Canvas Architecture
Yjs manages state through shared data types inside a central document container called a Y.Doc. To organize a multi-user canvas, separate permanent drawing elements from temporary metadata:
- Drawing Strokes (
Y.Array): An ordered shared array holding completed paths. Each path object contains coordinates, line width, and color. - User Cursors & Presence (Yjs Awareness Protocol): Transient metadata such as active mouse coordinates and user selection colors. This data is broadcast without being saved into permanent document history.
Step 1: Setting Up the Yjs WebSocket Server
The backend server serves as a lightweight message broker. It receives binary CRDT updates from one client and broadcasts them to all other clients connected to the same document room.
const http = require('http');
const WebSocket = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Yjs WebSocket Gateway Running');
});
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (request, socket, head) => {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
wss.on('connection', (ws, req) => {
const roomName = req.url.slice(1) || 'default-room';
setupWSConnection(ws, req, { docName: roomName });
});
server.listen(8080, () => {
console.log('WebSocket server listening on port 8080');
});
Step 2: Connecting the HTML5 Canvas Frontend
On the front end, draw strokes locally to maintain immediate visual feedback. Once a stroke finishes (on mouseup), push the path into the shared Y.Array. Yjs computes the binary diff and broadcasts it to all connected peers.
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
const canvas = document.getElementById('drawing-canvas');
const ctx = canvas.getContext('2d');
const ydoc = new Y.Doc();
const provider = new WebsocketProvider('ws://localhost:8080', 'main-room', ydoc);
const sharedPaths = ydoc.getArray('paths');
let isDrawing = false;
let currentPoints = [];
canvas.addEventListener('mousedown', (e) => {
isDrawing = true;
currentPoints = [{ x: e.offsetX, y: e.offsetY }];
});
canvas.addEventListener('mousemove', (e) => {
if (!isDrawing) return;
currentPoints.push({ x: e.offsetX, y: e.offsetY });
// Draw local feedback stroke
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
});
canvas.addEventListener('mouseup', () => {
if (!isDrawing) return;
isDrawing = false;
// Commit complete stroke to shared CRDT array
sharedPaths.push([{
points: currentPoints,
color: '#166534',
width: 3
}]);
});
// Redraw full state when remote updates arrive
sharedPaths.observe(() => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
sharedPaths.forEach((path) => {
if (path.points.length < 2) return;
ctx.beginPath();
ctx.strokeStyle = path.color;
ctx.lineWidth = path.width;
ctx.moveTo(path.points[0].x, path.points[0].y);
for (let i = 1; i < path.points.length; i++) {
ctx.lineTo(path.points[i].x, path.points[i].y);
}
ctx.stroke();
});
});
Step 3: Enabling Offline Persistence with IndexedDB
CRDT architectures support offline usage naturally. By integrating y-indexeddb, users can continue drawing even if their network drops. When reconnected, Yjs merges local offline edits with remote server edits without overwriting work.
import { IndexeddbPersistence } from 'y-indexeddb';
const localProvider = new IndexeddbPersistence('main-room', ydoc);
localProvider.on('synced', () => {
console.log('Canvas state restored from local IndexedDB storage.');
});
Scaling Across Multiple Node.js Instances
A single WebSocket server process can handle thousands of concurrent client connections, but scaling horizontally across multiple servers requires an orchestration layer:
- Redis Pub/Sub: Connect WebSocket nodes to a shared Redis Pub/Sub channel. When Node A receives an update, it publishes the update to Redis so Node B can broadcast it to its locally connected clients.
- Sticky Sessions: Configure load balancers (such as NGINX or AWS ALB) with sticky session routing to keep clients in the same collaboration room on the same backend node when possible.
Limitations and Best Practices
- Canvas Re-rendering Overhead: Clearing and re-rendering the entire canvas on every update slows down performance as paths increase. Use layered offscreen canvases or vector libraries (like Konva.js or Paper.js) for large documents.
- Point Compression: Storing thousands of individual raw cursor points uses unnecessary memory. Simplify stroke paths using algorithms like Ramer-Douglas-Peucker before saving them into the
Y.Array. - Transient Data Storage: Avoid storing fast-changing data like continuous mouse movements in
Y.MaporY.Array. Use the Yjs Awareness Protocol instead to prevent bloat in the CRDT event log.
References
- Yjs Official Documentation
- MDN WebSockets API Reference
- y-websocket Provider Source & Documentation
- ws: Node.js WebSocket Library on npm
How does Yjs resolve conflicts if two users draw at the exact same location?
Yjs assigns deterministic client identifiers and operation counters to every stroke. Instead of rejecting or overwriting updates, both strokes are retained and inserted into the shared Y.Array in a deterministic order consistent across all connected devices.
Why shouldn't live mouse cursor movements be saved to Y.Array?
Saving high-frequency mouse movements to standard CRDT arrays creates permanent history tombstones, causing document size to inflate rapidly. Using the Yjs Awareness API broadcasts temporary cursor positions directly without adding permanent operations to the document.
Can Yjs canvas applications work completely offline?
Yes. By binding a local persistence provider such as y-indexeddb, local updates are stored in the browser's IndexedDB database. When the user reconnects, Yjs automatically syncs differences with the remote server.
How do you authenticate users joining a collaborative Yjs room?
Authentication is handled at the HTTP upgrade request level on the WebSocket server. You can verify JSON Web Tokens (JWT) or session cookies before completing the WebSocket connection handshake.