Step-by-Step Guide: How I Built a Multi-User Collaborative Canvas Using WebSockets and Yjs

“Learn how to orchestrate Conflict-free Replicated Data Types (CRDTs) for responsive, zero-conflict real-time collaborative web applications.”

Step-by-Step Guide: How I Built a Multi-User Collaborative Canvas Using WebSockets and Yjs

Step-by-Step Guide: How I Built a Multi-User Collaborative Canvas Using WebSockets and YjsWhen our team was tasked with building an interactive, multi-user digital whiteboard, we initially underestimated the complexity of state synchronization. Our first prototype used naive WebSockets to broadcast the absolute position of every cursor and path drawn on the screen. It took only three concurrent users with varying network latency to turn our elegant canvas into a chaotic mess of overwritten paths and out-of-order strokes.In this guide, I will share the exact architectural decisions, code patterns, and scaling strategies we used to move away from chaotic state overwrites to a highly resilient, offline-first collaborative drawing tool using Yjs—a high-performance Conflict-free Replicated Data Type framework—and standard Node.js infrastructure.Step 1: Choosing CRDTs Over Operational TransformationBefore writing code, we had to choose between Operational Transformation (OT) and CRDTs (Conflict-free Replicated Data Types). OT is the underlying technology of Google Docs. It relies heavily on a central coordinating server to sequence events and resolve conflicts. While powerful, OT is notoriously difficult to implement from scratch and requires a stateful backend that handles the brunt of the processing load.We chose CRDTs because they are decentralized by nature. In a CRDT-based system, any client can make local mutations to a document without waiting for server confirmation. When mutations eventually propagate to other peers, the data structures are designed to merge automatically and converge on the identical state, regardless of the order in which updates are received. Yjs is the fastest open-source CRDT library for JavaScript, offering near-instantaneous operations on complex data collections.Step 2: Designing the Shared Document ArchitectureIn Yjs, all collaborative data resides inside a single Y.Doc container. This container holds shared types such as maps, arrays, and text. For our collaborative canvas, we defined our data layout as follows:Shared Canvas Paths (Y.Array): A sequential list containing drawing paths. Each path is a structured JavaScript object containing drawing points, color, and line thickness.User Presence Metadata (Y.Map): A key-value mapping containing active users, their cursor coordinates, names, and color schemes.By splitting the shared state this way, we isolated heavy canvas elements from fast-changing, transient metadata like cursor movements.Step 3: Setting Up a Resilient WebSocket BackendWe built our communication hub using Node.js and the robust ws package. The server acts as a central distribution point, forwarding document updates from one client to all other connected clients without needing to parse or understand the binary payload itself. This makes the gateway highly performant.Here is the base configuration of our production-tested collaborative synchronization server:const WebSocket = require('ws');
const http = require('http');
const Y = require('yjs');
const { setupWSConnection } = require('y-websocket/bin/utils');

const server = http.createServer((request, response) => {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.end('Collaborative WebSocket Gateway Running\n');
});

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) => {
// setupWSConnection binds Yjs document updates to the WebSocket channel
const docName = req.url.slice(1) || 'default-room';
setupWSConnection(ws, req, { docName });

console.log([WebSocket] New client connected to room: ${docName});

ws.on('close', () => {
console.log('[WebSocket] Client disconnected');
});
});

const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
console.log(Server started on port ${PORT});
});This implementation relies on y-websocket/bin/utils, which internally handles binary-encoded messages to sync document states using two main message types: sync updates and awareness data. This structure keeps our network traffic incredibly lightweight.Step 4: Building the Shared Canvas FrontendOn the front end, we must bind our drawing canvas changes to the Yjs Shared Array. Below is the core module that instantiates our collaborative document, opens the WebSocket connection, and registers event listeners to redraw the canvas whenever the shared array updates.import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

class CollaborativeCanvas {
constructor(canvasElement, roomId) {
this.canvas = canvasElement;
this.ctx = this.canvas.getContext('2d');
this.isDrawing = false;
this.currentPathPoints = [];

// 1. Initialize the Yjs Document
this.ydoc = new Y.Doc();

// 2. Establish connection to our Node.js gateway
this.provider = new WebsocketProvider('ws://localhost:8080', roomId, this.ydoc);

// 3. Obtain the shared array
this.sharedPaths = this.ydoc.getArray('paths');

// 4. Register event listeners
this.sharedPaths.observe(() => this.renderCanvas());
this.setupLocalInputListeners();
}

setupLocalInputListeners() {
this.canvas.addEventListener('mousedown', (e) => {
this.isDrawing = true;
this.currentPathPoints = [{ x: e.offsetX, y: e.offsetY }];
});

this.canvas.addEventListener('mousemove', (e) => {
if (!this.isDrawing) return;
this.currentPathPoints.push({ x: e.offsetX, y: e.offsetY });
this.drawLocalStroke(e.offsetX, e.offsetY);
});

this.canvas.addEventListener('mouseup', () => {
if (!this.isDrawing) return;
this.isDrawing = false;

// Save the complete path into the shared Yjs array
this.sharedPaths.push([{
points: this.currentPathPoints,
color: '#166534',
width: 4
}]);
});
}

drawLocalStroke(x, y) {
this.ctx.strokeStyle = '#166534';
this.ctx.lineWidth = 4;
this.ctx.lineCap = 'round';
this.ctx.lineTo(x, y);
this.ctx.stroke();
this.ctx.beginPath();
this.ctx.moveTo(x, y);
}

renderCanvas() {
// Clear canvas before full state redraw
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);

// Iterate over the shared array to render all synchronized elements
this.sharedPaths.forEach((path) => {
if (path.points.length < 2) return;
this.ctx.beginPath();
this.ctx.strokeStyle = path.color;
this.ctx.lineWidth = path.width;
this.ctx.lineCap = 'round';

this.ctx.moveTo(path.points[0].x, path.points[0].y);
for (let i = 1; i < path.points.length; i++) {
this.ctx.lineTo(path.points[i].x, path.points[i].y);
}
this.ctx.stroke();
});
this.ctx.beginPath();
}
}In the snippet above, we avoid directly sending coordinates over raw sockets during the path generation. Instead, we draw a local path instantly on screen for responsive visual feedback, and commit the complete stroke to the shared array on mouseup. Yjs distributes only the binary delta changes to all connected peers, keeping updates efficient and minimal.Step 5: Storing State Locally with IndexedDBOne of the best benefits of a CRDT system is its offline compatibility. If a user temporarily loses connectivity, they can continue sketching. Once reconnecting, Yjs syncs the changes. To prevent losing all client progress during clean reloads or offline sessions, we configured client-side storage with IndexedDB.We integrated the y-indexeddb persistence provider. Now, the application checks the local storage before reaching out over the network:import { IndexeddbPersistence } from 'y-indexeddb';

// Instantiating persistence alongside our room definition
const localProvider = new IndexeddbPersistence('canvas-room-1', ydoc);

localProvider.on('synced', () => {
console.log('Local document state successfully loaded from IndexedDB!');
});This addition ensures that work can be recovered and synced to the cloud seamlessly, providing an uninterrupted user experience even on unstable networks.Step 6: Scaling WebSockets with Redis Pub/SubAs traffic scaled, our single Node.js process hit CPU limits. Because WebSocket connections must stay persistently open, you cannot simply throw a standard round-robin load balancer in front of multiple server instances. Clients connected to Server A won't receive updates from clients connected to Server B.To solve this in production, we implemented Redis Pub/Sub. Whenever our WebSocket server receives a binary update from a connected client, it broadcasts that payload to a global Redis channel matching the room name. All other instances of our WebSocket server listen to these Redis channels and forward updates to their respective local clients. To ensure clients working on the same canvas room end up on the same server, we set up sticky sessions at our ingress load balancer level (e.g., NGINX or AWS ALB).Frequently Asked Questions (FAQs)How does Yjs prevent memory leaks when documents grow very large?Yjs optimizes garbage collection internally by merging adjacent operations. However, to keep memory usage low, it is crucial to perform garbage collection on deleted items (which Yjs does by default by replacing deleted content with smaller tombstones). Additionally, for highly dynamic maps with continuous coordinates like mouse cursors, utilize the Yjs Awareness Protocol, which explicitly avoids persisting transient data to the document state history entirely.What happens when two users draw over the exact same spot at the same time?With CRDTs, there is no system freeze or rejection of updates. Because paths are added to a chronological list (Y.Array), both paths will render correctly. Since each path is assigned a unique, deterministic identifier based on the user's client ID and local transaction counter, the elements are deterministically ordered across all screens, preventing visual overlapping conflicts.Can I secure individual collaboration rooms?Yes. The WebSocket handshake is standard HTTP. You can validate JSON Web Tokens (JWT) or check session cookies during the connection initialization process on your upgrade request handler before delegating the connection to Yjs, ensuring only authenticated users can join specific canvas rooms.

Shanawar AliFounder and developer at S Pro Coder, sharing practical coding and technology guides.