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:

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:

Limitations and Best Practices

References

## Continue learning Explore more practical guides in our [latest articles](/blog) and [free courses](/courses).

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.