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
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:
- It's broadcast instantly over WebSocket to everyone else in the room (no DB round-trip in the critical path)
- The shape event is pushed onto a BullMQ queue backed by Redis
- 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
onmessagehandler - 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)