Lattice
A realtime collaborative whiteboard where many people draw on one canvas at the same time, backed by a self-hosted CRDT sync server instead of a realtime SaaS.
Aug 2026 - Present • Less than a month • Ongoing
Tech Stack
TypeScriptNext.jsReactNode.jsExpressWebSocketYjs / CRDTPrismaMySQLNginx
Features
- 🎨 Realtime multiplayer canvas - Many people drawing on one board, live cursors and selection
- 🔀 Self-hosted CRDT sync - Yjs wire protocol implemented directly, no Liveblocks or Convex
- 👥 Workspaces & sharing - Role-based access on both workspaces and individual boards
- 💾 Snapshot persistence - Boards written to MySQL on a throttle, evicted from memory when empty
- 🔐 Auth before handshake - JWT verified before the WebSocket upgrade completes
- ↩️ Per-client undo - Scoped so you never undo somebody else’s work
- ⚡ 60fps drag - Commits to the CRDT on drag end, never inside the pointer loop
Why CRDTs
CRDTs form a join-semilattice, and merging two replicas is the lattice join — commutative, associative, idempotent. Clients converge on the same board no matter what order updates arrive in, so there is no server-side arbiter anywhere in the design. Hence the name.
Engineering notes
- Three kinds of state, kept apart - Shape data in
Y.Doc, UI state in Zustand, presence in Yjs awareness. Conflating them is what makes collaborative canvases janky. - Throttle, not debounce - A debounce keeps deferring the write for as long as someone keeps drawing, which is exactly when you want it to have landed.
- 404, not 403 - Distinguishing “forbidden” from “missing” is a way to enumerate which board ids exist.
- Next.js never touches the database - Everything crosses one typed HTTP/WS boundary, so one component owns MySQL and one file owns the contract.
Trade-offs
- Sync server is single-instance — board documents live in process memory, so scaling out needs Redis pub/sub or
y-redis - Only the newest snapshot is stored — restore is exact, but “revert to yesterday” isn’t possible
- Logout clears the cookie but doesn’t revoke the JWT
viewerrole is defined but not enforced — writes travel over WebSocket, not REST