DEV Community

Vivek
Vivek

Posted on

Building Pixel Room — a Real-Time Collaborative Whiteboard (Excalidraw Clone)

Why I built this

I wanted to actually understand how tools like Excalidraw and Figma pull off real-time multiplayer canvas sync — so instead of reading about it, I built my own version. Meet Pixel Room: a collaborative whiteboard where you create a room, draw shapes with your crew, and chat live while you sketch.

It's still a work in progress, but the core sync engine works, and I learned a lot building it. Here's the breakdown.

The stack

  • Turborepo monorepo — shared packages across frontend, backend, and the WebSocket server
  • React + TypeScript frontend, rendering on HTML5 Canvas
  • Node.js + Express for the main backend/API
  • A dedicated ws-server purely for real-time WebSocket traffic
  • PostgreSQL + Prisma for persistence
  • BullMQ + Redis for async job processing

Monorepo structure

One decision I'm glad I made early: keeping shared DB logic in packages/db instead of duplicating Prisma client setup inside each app. Both the Express backend and the ws-server import from the same package, so schema changes propagate everywhere without copy-pasting client code.

apps/
  web/          → React frontend
  http-backend/ → Express API
  ws-server/    → WebSocket server
packages/
  db/           → Prisma client + schema, shared
  ui/           → shared components
Enter fullscreen mode Exit fullscreen mode

Real-time sync: the core problem

The obvious naive approach — write every shape straight to Postgres the moment it's drawn — falls apart fast. Every stroke, every drag, every resize would be a DB write. Multiply that by multiple users in a room and you get a database that can't keep up, plus laggy canvas feedback.

The fix: WebSockets for sync, queue for persistence.

When a user draws a shape:

  1. It's broadcast instantly over WebSocket to everyone else in the room (no DB round-trip in the critical path)
  2. The shape event is pushed onto a BullMQ queue backed by Redis
  3. A worker consumes the queue and persists to Postgres asynchronously

This keeps the canvas feeling instant for every connected client while the database gets written to at a sane, batched pace in the background.

The useWebSocket hook and pub/sub

One of the trickier bugs I hit was message duplication — the same draw event getting processed twice on a client. Turned out to be a classic React stale-closure issue: my WebSocket message handler was capturing an old version of state due to how refs and setState were timed relative to re-renders.

The fix was building a proper pub/sub pattern around the socket:

  • One actual WebSocket connection per client, held in a ref (not state, so it survives re-renders)
  • A subscribe/unsubscribe interface so multiple components (canvas, chat, room presence) can all listen to the same socket without each one attaching its own raw onmessage handler
  • Handlers dispatch based on event type, so adding new event kinds doesn't mean rewiring the whole hook

This one fix killed the duplication bug and made the codebase much easier to extend — adding chat messages as a new event type on the same socket was trivial afterward.

Canvas: shapes, infinite zoom, resize

The frontend renders directly to an HTML5 <canvas> rather than SVG, for performance with many shapes. Current tool support:

  • Rectangle, ellipse, triangle, straight line, and freehand draw
  • Responsive resize handling so the canvas adapts to different screen sizes without shape positions drifting

What's next

  • Infinite canvas with zoom/pan — panning and zooming into an unbounded space instead of a fixed viewport
  • Undo/redo — needs an action history stack that stays consistent across clients, not just a local-only undo
  • More shape types and a proper eraser
  • Better reconnection handling for flaky connections mid-draw
  • Room persistence so canvases survive server restarts, not just active sessions

Still very much in progress — but the real-time sync core taught me more about WebSockets, race conditions, and queue-based architecture than any tutorial could have. Happy to go deeper into any part of this (the pub/sub hook, the BullMQ setup, the Prisma schema) in the comments if anyone wants specifics.

Top comments (0)