Building a collaborative canvas
Table of Contents
Why build your own collaborative canvas? #
Most collaborative drawing tools try to do too much. They add layers, complex shape tools, text editing, commenting systems, and lots of features that get in the way.
The experience should be simple enough that anyone can jump in and start drawing immediately without learning a complex UI.
When someone draws a line, everyone else should see it appear. When someone erases, it should disappear for everyone. That’s it.
The technical approach #
The real-time foundation #
For the canvas to feel natural, several key interactions need to happen in real-time. When someone draws a stroke, all other users should see it appear instantly. When someone erases, the marks should disappear for everyone simultaneously.
Also, when new users join, they should immediately see the current state of the canvas.
Traditionally, we’d use Websockets to implement this kind of real-time functionality. That requires managing persistent connections, handling reconnection logic, dealing with connection states, and building custom broadcasting infrastructure.
Instead of building our own WebSocket infrastructure, we leveraged Supabase’s real-time features. This gives us instant synchronization without handling the complexity ourselves.
Every drawing operation gets saved and immediately broadcasted to all connected users.
Canvas drawing with React hooks #
The drawing logic is encapsulated in a custom hook that handles the entire canvas lifecycle:
Core drawing hook structure
export const useDrawingCanvas = (canvasId: string) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [currentTool, setCurrentTool] = useState<'draw' | 'erase'>('draw');
const [currentPath, setCurrentPath] = useState<Point[]>([]);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsDrawing(true);
const point = getEventPoint(e, canvasRef.current);
setCurrentPath([point]);
}, []);
// Mouse move and up handlers for path tracking...
};
This pattern keeps the drawing state isolated and reusable while maintaining clean separation between the React component lifecycle and the canvas drawing operations.
Optimistic updates with conflict resolution #
One of the trickiest parts of real-time collaboration is handling the inevitable race conditions. When two people draw at the same time, how do you ensure consistency?
Operation tracking and conflict resolution
const handleSaveDrawingOperation = useCallback(async (path: DrawingPath) => {
try {
const data = await saveDrawingOperation(canvasId, path);
// Track this operation as our own to avoid re-drawing
if (data) {
ownOperationIdsRef.current.add(data.id);
}
} catch (error) {
console.error('Failed to save drawing operation:', error);
}
}, [canvasId]);
We use optimistic updates: draw locally first for responsiveness, then sync to the database. Each operation gets a unique ID, and we track which operations originated from the current user to avoid drawing them twice when they come back through the real-time subscription.
Building the canvas experience #
HTML5 Canvas with touch support #
The drawing engine itself is quite straightforward. HTML5 Canvas provides everything we need for drawing and erasing, with the main complexity being proper event handling across mouse and touch devices.
The eraser tool uses Canvas’s destination-out
composite operation, which literally removes pixels rather than drawing white over them. This creates a more natural erasing experience and works regardless of background color.
Canvas setup and drawing utilities
export const setupCanvas = (canvas: HTMLCanvasElement) => {
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = 2;
ctx.strokeStyle = '#000000';
};
export const drawPath = (ctx: CanvasRenderingContext2D, path: DrawingPath) => {
if (path.points.length < 2) return;
if (path.type === 'erase') {
ctx.globalCompositeOperation = 'destination-out';
ctx.lineWidth = 10;
} else {
ctx.globalCompositeOperation = 'source-over';
ctx.lineWidth = 2;
}
ctx.beginPath();
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();
};
URL sharing #
Canvas sharing is handled through simple URL routing. Each canvas gets a unique ID, and sharing is as simple as copying the URL. No complex permission systems or user accounts required—if you have the link, you can collaborate.
React Router handles the URL parsing, and the canvas component automatically connects to the real-time subscription for that specific canvas ID.
Canvas management and sharing
const SharedCanvasView: React.FC = () => {
const { canvasId } = useParams();
const navigate = useNavigate();
if (!canvasId) {
return <div>Canvas not found</div>;
}
return (
<div className="flex flex-col h-screen">
<DrawingCanvas canvasId={canvasId} />
</div>
);
};
The persistence strategy #
Every drawing operation gets stored in Supabase as a structured record in a drawing_operations
table. Rather than saving canvas snapshots, we store individual drawing actions that can be replayed to reconstruct the current state.
Data structure #
Each drawing operation contains these key entities:
- canvas_id: Unique identifier linking operations to a specific canvas
- operation_type: Either ‘draw’ or ’erase’ to distinguish between drawing and erasing actions
- path_data: JSON object containing the actual drawing path points and metadata
- created_at: Database timestamp for chronological ordering
The path data itself contains an array of coordinate points and timing information:
Example drawing operation record
{
"id": "123e4567-e89b-12d3-a456-426614174000",
"canvas_id": "canvas_abc123",
"operation_type": "draw",
"path_data": {
"points": [
{ "x": 150, "y": 200 },
{ "x": 151, "y": 201 },
{ "x": 153, "y": 203 },
{ "x": 156, "y": 205 }
],
"timestamp": 1671234567890
},
"created_at": "2023-12-17T10:30:00Z"
}
Reconstruction process #
When someone joins a canvas, we query all operations for that canvas ID, ordered by creation timestamp, then replay each operation in sequence. Draw operations add strokes to the canvas, while erase operations remove pixels using Canvas’s composite operations.
Loading and replaying operations
export const loadExistingDrawings = async (canvasId: string) => {
const { data: operations, error } = await supabase
.from('drawing_operations')
.select('*')
.eq('canvas_id', canvasId)
.order('created_at', { ascending: true });
operations.forEach((op) => {
const pathData = op.path_data as PathData;
if (pathData && pathData.points) {
handleDrawPath({
points: pathData.points,
type: op.operation_type as 'draw' | 'erase',
timestamp: pathData.timestamp
});
}
});
};
This approach has several advantages: complete version history, the ability to implement undo/redo in the future, and natural conflict resolution since operations are ordered by timestamps.
Unlike complex drawing apps that save the entire canvas state as an image, storing individual operations keeps the data lightweight and enables more sophisticated features down the line.
Concluding thoughts #
The complete code is available on GitHub at https://github.com/pdulapalli/basic-collaborative-canvas. Give it a try!
Also, feel free to check out the live demo of this app at https://basic-collaborative-canvas.lovable.app/!