Building a Collaborative Multi-User Canvas0%

Building a Collaborative Multi-User Canvas

Beginner12 min read•Updated: Jul 11, 2026
Study MaterialsCheatsheet

Building a Collaborative Multi-User Canvas

Building a multi-user collaborative workspace (such as Figma, Miro, or Google Docs) requires synchronizing mouse cursors, object transformations, and canvas states in real-time across distributed clients. Managing collaborative state demands high-frequency event throttling, conflict resolution strategies, and performant HTML5 Canvas rendering.


1. Collaborative Canvas Architecture

Output
Client A (Cursor: 240, 180) ──► WebSocket ──► Socket Server (Broadcasts to Room)
│
Client B (Sees A's cursor move in real-time) ◄──────┤
Client C (Sees A's cursor move in real-time) ◄──────┘

Key engineering challenges:

  1. 1
    Event Throttling: Transmitting raw onMouseMove events 120 times per second floods network sockets. Throttling to 30-60ms intervals optimizes bandwidth.
  2. 2
    State Decoupling: Transient data (remote cursor positions) should never be stored in heavy React component state; mutating canvas or DOM elements directly bypasses re-render bottlenecks.
  3. 3
    Optimistic Rendering: Local brush strokes render immediately on the local canvas before broadcasting coordinate vectors to peers.

2. Implementing Multi-User Live Drawing

TSX
import React, { useRef, useEffect, useState } from "react";
import { io, Socket } from "socket.io-client";
 
interface DrawStroke {
prevX: number;
prevY: number;
currX: number;
currY: number;
color: string;
}
 
interface PeerCursor {
userId: string;
x: number;
y: number;
color: string;
}
 
export function CollaborativeCanvas({ roomId, userId }: { roomId: string; userId: string }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const socketRef = useRef<Socket | null>(null);
const isDrawing = useRef(false);
const lastPos = useRef({ x: 0, y: 0 });
 
const [peerCursors, setPeerCursors] = useState<Record<string, PeerCursor>>({});
 
useEffect(() => {
// 1. Establish dedicated real-time socket
const socket = io("https://canvas.enterprise.com", {
query: { roomId, userId },
});
socketRef.current = socket;
 
// 2. Receive remote draw commands and paint onto canvas
socket.on("draw:stroke", (stroke: DrawStroke) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
 
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.moveTo(stroke.prevX, stroke.prevY);
ctx.lineTo(stroke.currX, stroke.currY);
ctx.stroke();
});
 
// 3. Receive remote cursor coordinates
socket.on("cursor:update", (cursor: PeerCursor) => {
setPeerCursors((prev) => ({
...prev,
[cursor.userId]: cursor,
}));
});
 
socket.on("user:left", (leftUserId: string) => {
setPeerCursors((prev) => {
const next = { ...prev };
delete next[leftUserId];
return next;
});
});
 
return () => {
socket.disconnect();
};
}, [roomId, userId]);
 
// Handle local drawing
const handleMouseDown = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
lastPos.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
isDrawing.current = true;
};
 
const handleMouseMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const currX = e.clientX - rect.left;
const currY = e.clientY - rect.top;
 
// Broadcast cursor position (throttled in production)
socketRef.current?.emit("cursor:move", {
roomId,
userId,
x: currX,
y: currY,
color: "#06b6d4",
});
 
if (!isDrawing.current) return;
 
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
 
// Draw locally immediately
ctx.beginPath();
ctx.strokeStyle = "#06b6d4";
ctx.lineWidth = 3;
ctx.lineCap = "round";
ctx.moveTo(lastPos.current.x, lastPos.current.y);
ctx.lineTo(currX, currY);
ctx.stroke();
 
// Broadcast stroke to peers
socketRef.current?.emit("draw:stroke", {
roomId,
prevX: lastPos.current.x,
prevY: lastPos.current.y,
currX,
currY,
color: "#06b6d4",
});
 
lastPos.current = { x: currX, y: currY };
};
 
const handleMouseUp = () => {
isDrawing.current = false;
};
 
return (
<div className="relative p-6 bg-slate-900 border border-slate-800 rounded-2xl">
<h2 className="text-white font-bold mb-3 text-sm">Collaborative Architecture Canvas (Room: {roomId})</h2>
 
<div className="relative inline-block border border-slate-700 rounded-xl overflow-hidden bg-slate-950">
<canvas
ref={canvasRef}
width={800}
height={500}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
className="cursor-crosshair block"
/>
 
{/* Remote Peer Cursors Overlay */}
{Object.values(peerCursors).map((peer) => (
<div
key={peer.userId}
style={{
position: "absolute",
top: `${peer.y}px`,
left: `${peer.x}px`,
pointerEvents: "none",
transition: "top 50ms linear, left 50ms linear",
}}
className="flex items-center gap-1 z-20"
>
<div
style={{ backgroundColor: peer.color }}
className="h-3 w-3 rounded-full border border-white shadow-md"
/>
<span
style={{ backgroundColor: peer.color }}
className="px-1.5 py-0.5 text-[10px] font-bold text-slate-950 rounded shadow"
>
{peer.userId}
</span>
</div>
))}
</div>
</div>
);
}

3. Conflict Resolution with CRDTs

When multiple users mutate text or structured document nodes concurrently, standard last-write-wins models overwrite peer edits. Enterprise collaborative apps use Conflict-Free Replicated Data Types (CRDTs) (e.g. Yjs or Automerge) to mathematically converge distributed edits without central lock synchronization.


Interactive Quiz

Practice Quiz

Question 1
Why is throttling mousemove events essential when broadcasting cursor coordinates over WebSockets?
Question 2
Why should transient drawing coordinates be drawn directly on HTML5 Canvas rather than stored in React useState?
Question 3
What technology is used by modern collaborative apps (like Figma and Notion) to merge concurrent document edits without conflicts?
Question 4
What does pointer-events: none accomplish on remote cursor overlay elements?
Question 5
How does optimistic local drawing prevent lag for the active user?
Next Lesson

Internationalization Setup with i18next and react-i18next

Continue learning with hands-on practice, examples, and exercises in the upcoming topic.

Practice Quiz

Test your understanding of this lesson with 5 questions. Each question has one correct answer.

PrevNext