A real-time collaborative whiteboard where the interesting part isn't drawing — it's what happens underneath: a CRDT sync engine (Yjs) that keeps every board converged across simultaneous editors, offline edits, and server restarts.
Multiple users can sketch freehand, drop shapes and text on an infinite canvas, see each other's cursors live, and reload later — everything merges.
| Sync | Yjs CRDT over a custom binary WebSocket protocol |
| Canvas | React + Konva (react-konva) |
| Relay | Node + ws — dumb update relay + per-board authoritative room doc |
| Offline | IndexedDB cache; state-vector diff merge on reconnect |
| Persistence | Postgres snapshotting with explicit compaction strategy |
- Infinite canvas — freehand pen, rectangles, ellipses, arrows, and text; pan/zoom, multi-select, z-ordering, and delete via keyboard or panel.
- Full-spectrum color control — preset swatches plus an HSV wheel picker (hue/saturation disc, brightness slider, hex input). Picks are gated behind an explicit OK, and each tool keeps its own recently used palette that persists across reloads.
- Live collaboration — remote cursors, awareness-driven presence list, and advisory soft-lock hints; distinct users per board are capacity-capped while extra tabs of the same person still count once.
- Scoped undo/redo — your undo stack never reverts a peer's concurrent edit.
- Offline-first — edits made disconnected are cached in IndexedDB and merge through a state-vector diff on reconnect.
- Durable boards — snapshot restore-on-load from Postgres when configured; memory-only otherwise.
- Export & niceties — one-click PNG/SVG export, shareable board URLs,
keyboard shortcuts (
?shows the cheatsheet).
pnpm install
pnpm build:shared
pnpm dev # client on :5173 + relay on :3001, togetheror run them individually:
pnpm dev:server # relay on :3001 (memory-only without DATABASE_URL)
pnpm dev:client # Vite on :5173, proxies /ws to the relayOpen http://localhost:5173 and you land on the marketing page — it includes a
live, drawable demo with two scripted peers. Create a room (or any board
URL) drops you straight into the canvas: each board id lives in the URL hash
(/#abc123) — share it to collaborate. The brand chip in the board's top-left
returns to the landing page.
Optional durable persistence:
cp apps/server/.env.example apps/server/.env # set DATABASE_URLThe server logs Persistence : postgres on boot when configured; otherwise it
runs memory-only. Check any board's storage stats at /stats?board=<id>.
flowchart LR
subgraph Browsers
A["Browser A\nReact/Konva + Y.Doc"]
B["Browser B\nReact/Konva + Y.Doc"]
end
subgraph Relay["Node WS Relay"]
R["Room doc (per board)"]
AW["Awareness relay\ncursors · selections · soft locks"]
S["Snapshot scheduler\ndebounce 5s · ≥100 updates · ≥256KB"]
end
P[("Postgres\nboards.snapshot")]
I[("IndexedDB")]
A <-- "binary updates / awareness" --> R
B <-- "binary updates / awareness" --> R
R --- AW
R --- S
S -->|"merged full-state snapshot"| P
P -.->|"restore-on-load"| R
A -.->|"offline edits cached"| I
Full detail — wire protocol table, join handshake, compaction policy — lives in docs/architecture.md.
The point of this project is that conflict resolution is structural, not procedural. There is no lock server, no merge UI, no "last saved wins" dialog. Each of these is exercised by executable tests (see next section):
- Concurrent edits converge deterministically. Two users resizing the same rectangle produce identical final geometry on every replica. Fields merge last-writer-wins ordered by Yjs logical clock + client id — never wall-clock, so clock skew is irrelevant. A field may come from writer A while its sibling comes from writer B; both outcomes are valid and all peers agree. (CS-1)
- Delete wins races against edits. Deleting tombstones the entry: drag frames generated mid-deletion cannot resurrect it, even when replayed after the fact. (CS-2)
- Offline work is never lost. Edits made disconnected ride out through a state-vector diff on reconnect and merge with everything that happened in between — including other people editing the same shapes. (CS-3)
- No corruption from overlapping input. Distinct strokes are distinct keys; concurrent writes to one stroke resolve to a single intact version rather than an interleaved mess. (CS-4)
- Rendering stays consistent under metadata races. Concurrent z-index reorders converge to one mapping everywhere; paint order derives from that identical mapping via stable sort. (CS-5)
- Undo respects collaboration boundaries. Undo/redo is scoped by transaction origin: your undo stack physically cannot revert a peer's concurrent edit — inverse ops propagate like any other edit. (M9)
- Nothing blocks. The soft-lock indicator ("Alice is editing…", amber handles) is advisory UI only. CRDT convergence makes hard locks unnecessary for correctness; they'd only add latency.
Scenario-by-scenario mechanics and observed outcomes: docs/conflicts.md.
pnpm test:crdt # CS-1…CS-5 conflict scenarios against the real relay (spawns server)
pnpm test:undo # scoped-UndoManager guarantee checks against real client modulesBoth harnesses exit non-zero on failure and print per-check PASS/FAIL lines.
apps/
client/ # React + Konva whiteboard
src/crdt/ # ydoc, WS provider, awareness, IndexedDB, undo scoping
src/canvas/ # Board, shapes, cursors, overlays
scripts/ # undo-smoke.ts
server/ # Node WS relay + Postgres persistence
src/ # index.ts (relay), db.ts (snapshot store)
dist/ # built output (harness spawns this)
scripts/ # conflict-scenarios.ts (CS-1…CS-5)
packages/
shared/ # shape schema + protocol types shared by both apps
docs/ # architecture, conflicts, requirements, PRD, status
- Architecture — components, protocol, persistence pipeline
- Conflict scenarios — CS-1…CS-5 tested & documented
- Status & milestone log
- Product requirements · Requirements
Milestones M0–M11 complete; M12 polish done except deployment (intentionally deferred). Post-milestone UI work added the HSV color wheel picker with persistent recently-used colors. Remaining stretch ideas live in docs/status.md.