Skip to content
Back to Project Tutorials

Realtime Collaborative Whiteboard

Build a multiplayer whiteboard with CRDT sync, presence, and scalable WebSocket backends.

Design & Architecture

Choose CRDT vs OT, transport (WebSocket/WebRTC), and persistence model.

Why

Whiteboard architecture is locked in by the sync algorithm. CRDT (Yjs/Automerge) vs OT determines whether you support offline editing, peer-to-peer, and conflict semantics. Switching after launch means rewriting the core data layer and migrating every document.

When

Before building the canvas. Spend 1-2 weeks prototyping sync on a toy document and measuring conflict resolution on concurrent edits.

How

1. Choose the sync algorithm - CRDT (Yjs / Automerge): supports offline + P2P, no central server required for merge - OT (ShareDB): simpler mental model, but needs a central authority and online merge - For whiteboards with offline support, pick CRDT 2. Choose the transport - WebSocket (client-server): easier auth, works behind corporate proxies - WebRTC (P2P): lower latency for 2-5 users, no server cost, but NAT traversal is hard - Hybrid: WebRTC for presence, WebSocket for persistence 3. Design the document model - Y.Map for shape properties, Y.Array for z-order, Y.Text for labels - Each shape = a Y.Map nested in the root Y.Map - Avoid deep nesting — CRDT performance degrades on wide trees 4. Plan persistence - Store periodic snapshots (every 30s or 50 ops) to a DB - Replay op log between snapshots for full history - Garbage-collect ops older than the latest snapshot 5. Prototype and measure - Build a 2-client toy: type concurrently, disconnect, reconnect - Measure: no lost chars, no duplicates, convergence in < 1s - If the prototype diverges, fix the algorithm before building the canvas

Example

// Yjs document shape const ydoc = new Y.Doc(); const shapes = ydoc.getMap("shapes"); // Y.Map<string, Y.Map> const zOrder = ydoc.getArray("zOrder"); // Y.Array<string> // A rectangle is a nested Y.Map const rect = new Y.Map(); rect.set("type", "rect"); rect.set("x", 100); rect.set("y", 50); rect.set("w", 80); rect.set("h", 40); shapes.set("shape-1", rect); // Sync over WebSocket const wsProvider = new WebsocketProvider( "wss://sync.example.com", "room-42", ydoc ); // Offline edits merge automatically on reconnect — no manual conflict resolution

Common Problems

  • ⚠Choosing OT then needing offline support — OT cannot merge offline edits without a central server replay
  • ⚠Deeply nested CRDT trees — Y.Map inside Y.Map inside Y.Map causes quadratic merge cost on large boards
  • ⚠No snapshot strategy — reconnecting replays the full op log, taking seconds on a 10K-op document

Best Practices

  • ✓Pick CRDT (Yjs) if you need offline or P2P — it is the only algorithm that merges without a central authority
  • ✓Keep the document shallow: a flat Y.Map of shape Y.Maps, with a separate Y.Array for z-order
  • ✓Snapshot every 30s / 50 ops and garbage-collect old ops — reconnects load one snapshot + delta, not the full history

Implementation & Realtime Sync

Build the canvas, sync engine, and presence (cursors and selections).

Why

The sync engine is where whiteboards break — lost updates, cursor jitter, and reconnection storms. Presence (showing other users' cursors) must be low-latency but throttled, or a fast mover floods the room with 100 updates/sec and starves the document sync.

When

After the sync prototype validates conflict resolution. Build canvas + sync + presence, then shapes and export.

How

1. Render the canvas - Use a retained-mode renderer (Konva / PixiJS) for 1000+ shapes - Map each Y.Map shape to a scene node; observe Yjs events to update - Throttle re-renders to 60fps; batch Yjs updates per frame 2. Wire local edits to the CRDT - Drag -> rect.set("x", newX) — Yjs handles the op, sync, and merge - Never mutate shape objects directly — always go through the Y.Map 3. Implement presence with Yjs awareness - awareness.setLocalStateField("cursor", { x, y, color }) - Throttle cursor updates to 30/sec (not every mousemove) - Render remote cursors from awareness.getStates() — auto-cleans on disconnect 4. Handle reconnection - Show "reconnecting..." banner when wsProvider.synced = false - Buffer local edits during disconnect (Yjs does this automatically) - On reconnect, Yjs sends the buffered ops — no manual retry needed 5. Add undo/redo - Use Y.UndoManager on the shapes map - Track per-user undo stacks so one user's undo does not revert another's edit - Cap stack at 200 ops to bound memory

Example

// Presence via Yjs awareness (throttled) let lastCursorSent = 0; canvas.on("mousemove", (e) => { const now = Date.now(); if (now - lastCursorSent < 33) return; // 30fps cap lastCursorSent = now; awareness.setLocalStateField("cursor", { x: e.x, y: e.y, color: myColor, user: myName, }); }); // Render remote cursors awareness.on("change", () => { for (const [clientId, state] of awareness.getStates()) { if (clientId === ydoc.clientID) continue; // skip self renderCursor(state.cursor); } }); // Per-user undo const undoManager = new Y.UndoManager(shapes, { trackedOrigins: new Set([ydoc.clientID]), });

Common Problems

  • ⚠Sending cursor on every mousemove — 200 events/sec flood starves document sync and hits WebSocket frame limits
  • ⚠Mutating shape objects directly instead of through Y.Map — edits are local-only and lost on sync
  • ⚠Global undo stack — user A's undo reverts user B's edit, destroying collaboration trust

Best Practices

  • ✓Throttle presence updates to 30/sec and document edits to 60fps — bandwidth is the bottleneck, not the renderer
  • ✓Always edit through the CRDT API (Y.Map.set) — direct mutation bypasses sync and causes silent divergence
  • ✓Use Y.UndoManager with trackedOrigins per client so undo only reverts your own edits

Deployment & Scaling

Deploy WebSocket servers with sticky sessions, scale horizontally via Redis pub/sub, and persist snapshots.

Why

WebSocket servers hold in-memory Yjs documents — a crash loses unsynced edits. Horizontal scaling needs a pub/sub backbone (Redis) so a room on server A can sync with a client on server B. Without snapshot persistence, reconnecting reloads the full op history and tanks load time.

When

When the whiteboard works for 5 concurrent users. Load-test to 50 concurrent editors per room before launch.

How

1. Deploy the sync server - Node.js + y-websocket server, containerized (Docker) - Fly.io or Railway for easy scaling; set min instances = 2 for HA - Health check: GET /health returns 200; auto-restart on crash 2. Add Redis pub/sub for multi-server sync - y-redis adapter: publishes Yjs updates to a Redis channel - All servers subscribe; a room spans N servers transparently - Redis also persists updates — survives a full server restart 3. Snapshot to a DB - Every 30s or 50 ops, encode Y.encodeStateAsUpdate(ydoc) and store - On room load: fetch latest snapshot + replay ops since snapshot - Garbage-collect snapshots older than 7 days 4. Load-test before launch - 50 clients editing concurrently in one room for 5 min - Measure: p99 sync latency < 500ms, no lost edits, CPU < 70% - If sync latency spikes, increase Redis pool size or shard by room 5. Monitor and alert - Track: rooms active, concurrent users, sync latency p99, Redis lag - Alert: sync latency > 2s or a room's op log > 100K (snapshot needed) - Grafana dashboard + PagerDuty on the sync-latency alert

Example

// y-redis: multi-server sync + persistence import { RedisPersistence } from "y-redis"; const persistence = new RedisPersistence({ redisOpts: { host: "redis.internal", port: 6379 }, }); // y-websocket server with Redis backing import { setupWSConnection } from "y-websocket/bin/utils"; wss.on("connection", (conn, req) => { setupWSConnection(conn, req, { doc: ydoc, persistence }); // updates auto-published to Redis + persisted }); // Snapshot cron (every 30s per active room) setInterval(() => { for (const [room, ydoc] of activeRooms) { const snapshot = Y.encodeStateAsUpdate(ydoc); db.snapshots.upsert(room, snapshot, { ts: Date.now() }); } }, 30_000); // Load test: 50 clients, 5 min, expect p99 < 500ms // npx concurrently "node loadtest.js --clients 50 --room r1"

Common Problems

  • ⚠Single WebSocket server with no Redis — crashes lose all in-memory rooms and unsynced edits
  • ⚠No snapshot cron — reconnecting a 10K-op room replays the full log, taking 5+ seconds
  • ⚠Skipping the load test — works great with 3 demo users, falls over at 30 concurrent editors

Best Practices

  • ✓Back every WebSocket server with Redis pub/sub — it enables horizontal scaling and survives restarts for free
  • ✓Snapshot every 30s / 50 ops and load snapshot + delta on reconnect — bounds load time regardless of document age
  • ✓Load-test to 50 concurrent editors per room before launch — sync latency is the metric users feel

Full Developer Lifecycle

A 14-stage developer roadmap from idea to maintenance — visualize dependencies and track progress.

Next Step