</collab dashboard>
Shared whiteboard that survives a server kill and merges offline edits.

0 strokes
lost when the server process is killed — the board is restored into a browser with an empty IndexedDB
7 ms
end-to-end sync, LAN p50 (n=30) — 299 ms against the live host, ~285 ms of which is free-tier CPU throttling
0
writes accepted from a viewer driving the wire protocol with the interface bypassed entirely
SPECIFICATIONS
| ROLE | SOLO BUILD |
|---|---|
| YEAR | 2026 |
| TYPE | WEB APP |
| STATUS | LIVE · MIT · FREE-TIER HOST |
| STACK | react · yjs · hocuspocus · socket.io · sqlite · express · canvas · vite |
| LINKS | [live ↗][github ↗] |
| AVAILABILITY | collab-frontend-omega.vercel.app |
“A shared whiteboard is only worth anything if the board outlives the meeting, and this one did not.”
A collaborative whiteboard, live on the public internet, whose board is a Yjs CRDT written to SQLite.
== WHAT IS THIS ==
A collaborative whiteboard, live on the public internet, whose board is a Yjs CRDT written to SQLite. Two browsers draw on the same board in real time, edits made with the network pulled merge back in on reconnect, and the whole board comes back after the server process is killed — restored into a browser with an empty local cache, so it could only have come from the server. Roles are enforced at the document connection rather than in the interface, so a viewer that skips the UI and writes straight into the CRDT over the wire is refused. It runs on a free tier, and the page says what that costs.
== </the problem> ==
A shared whiteboard is only worth anything if the board outlives the meeting, and this one did not. Server state was a single in-memory Map deleted the moment the last user left; the creator of a board was assigned the viewer role and could not draw on it; presence reported nobody online while the socket was connected; and thirteen of the toolbar's own controls emitted events that no server handler was listening for. It also advertised a sync latency figure that nothing in the repository had ever measured.
role & context
Solo build, self-directed. Most of the repository was written by coding agents under my direction and the README says so; the audit that found the product did not work, the browser acceptance gates, and every number published here are mine.
== </my approach> ==
Everything durable about a board — strokes, shapes, text, comments, layers — moved into a Yjs document served by Hocuspocus and written to SQLite, which is what makes the board outlive both the people on it and the process serving it. Presence got the opposite treatment: cursors, camera and the stroke currently under someone's pen ride the Awareness protocol, broadcast and never written to disk. Roles moved to the document connection, so a viewer receives a read-only connection and the server rejects its updates whether or not it uses the interface. Then the claims got a harness: browser-level acceptance gates in benchmarks/ drive real Chromium instances against a running stack, and every number on this page is produced by one of them.
== </the story> ==
Collab Dashboard is a whiteboard two people can open in a browser and draw on together. What makes it worth a case study is not the drawing — it is that the board is a CRDT written to SQLite, so it survives things a whiteboard usually does not: someone editing with the network pulled, two people typing into the same text box, and the server process being killed outright.
It did not start there. An audit that ran the app rather than reading it found the product broken end to end: creating a board made you a viewer on your own board, presence read ONLINE (0) over a connected socket, and all state was an in-memory Map deleted when the last user left — under a boot banner that headlined "Persistence". Forty markdown files described the system, fourteen of them reporting on tests that did not exist.
The rebuild moved everything durable into a Yjs document and everything ephemeral onto Awareness, then went looking for the claims. Thirteen client events had no server handler at all. Seven more bugs surfaced the moment tests existed, including corner detection so inverted that a 48-point rectangle scored 46 corners — which meant hand-drawn rectangles, triangles and diamonds could never be recognised. The last one was found by recording the demo: a zigzag being silently straightened, because straightness was measured locally and a smooth wave is locally straight everywhere.
It is now deployed — a static frontend on Vercel talking to a Node process on Fly in London, because a collaborative session is a long-lived WebSocket and serverless cannot hold one. The deploy immediately produced a bug that nine sprints of local work never could, and it produced an uncomfortable number: 299 ms of sync latency, against 7 ms on a LAN. Taking that number apart is the last thing the project did.
== </architecture> ==
The frontend is React 19 on Vite 7, rendering to a Canvas element. The backend is Express 5 with Socket.io and Hocuspocus, speaking two protocols on one port: a Yjs document surface at /collaboration carrying strokes, shapes, text, comments, layers and embeds, and a socket.io control plane carrying session lifecycle, roles, tool selection and the activity feed. Both are served by one process, because free-tier hosting gives you one.
Durability splits along one line. Everything on the document surface is persisted to SQLite through the Hocuspocus SQLite extension, on a mounted volume — without that mount every deploy would wipe every board, which is written directly above the mount block in fly.toml. Everything on the Awareness channel — cursors, camera, is-drawing, the stroke currently in flight — is broadcast and never written, because presence answers who is here now and persisting it would only restore ghosts.
The document model carries five rules that exist because breaking them fails silently. A stroke is one operation holding an immutable point array, never one per sampled point. Text is the exception and uses Y.Text, updated by diffing rather than clear-and-rewrite, so two people editing one body merge instead of overwriting. Nothing ephemeral goes in the document. Ordering is explicit, because a Y.Map has no meaningful iteration order, so every element carries a sequence number and render order is a sort. And the map key is the id, so element values must not carry their own.
Membership and roles live in SQLite behind the control plane, keyed by a stable client id rather than a socket id — socket ids are minted fresh on every connection, so keying membership by one would mean a reload makes you a stranger and the creator of a board returns to it as a viewer with no way back in. On every document connection the server looks that role up and marks viewer connections read-only. A role change re-evaluates only on a new connection, so the role-updated event carries a flag telling the affected client to reopen its document.
== </key features> ==
The board outlives the process
Kill the server, start a new one, reopen the id, and the ink is still there, restored into a browser profile with an empty IndexedDB, which is how you know it came from the server rather than a client cache.
Offline edits merge both ways
One editor is taken offline at the network level and keeps drawing while another, still connected, draws too. On reconnect both boards hold both sets of work. A last-write-wins system loses one side here; that is the entire reason for choosing a CRDT.
Permissions that hold at the wire
A viewer gets a read-only document connection, so a client that bypasses the interface and writes straight into the shared type is refused server-side. Document tokens are scoped to one board: a valid token for board A cannot open board B.
Presence that stays attached to the board
Remote cursors are published in canvas space rather than screen space, so they stay on the content no matter how either side has panned or zoomed, and they ease on a time-based curve that converges identically at 30, 60 and 144 Hz. Remote strokes draw as their points arrive; your own ink is painted before any document write, so the whole latency budget is spent on other people's smoothness and none on your own.
Shape recognition, stated accurately
A rough stroke snaps to a clean rectangle, circle, line, triangle, diamond or arrow. It is geometry, not a model, and auto-keep only fires above 0.85 confidence — below that, doing nothing leaves your own stroke exactly as you drew it.
A whole board, with nothing decorative left in it
Layers with reorder and visibility, five templates, threaded comments with resolve, text bodies that merge character by character instead of clobbering, YouTube and Vimeo embeds, and PNG/SVG/JSON export — all of it real document state, where thirteen of those controls previously emitted into nothing.
== </key decisions> ==
DECISION 01
A CRDT for the document, over Redis or Postgres. Persistence, conflict resolution and offline editing are three problems, and Yjs answers all three with one move — it is also what production whiteboards actually use. The bill comes as modelling discipline: one operation per finished stroke holding an immutable point array, never one per sampled point, or a minute of drawing produces thousands of operations that CRDT history can never compact away. That rule is enforced by a test rather than by a comment.
DECISION 02
Roles enforced at the document connection, never inside the CRDT. A CRDT hands every connected client a writable handle on the shared type, including any field describing their own role, so a permission model stored in the document is a permission model any client can edit. Hiding the toolbar from a viewer is decoration. Roles therefore live in SQLite behind the control-plane socket, and the document connection consults them before handing over a writable document — verified by driving the wire protocol with a valid viewer token and no interface involved.
DECISION 03
Cursors, camera and the in-progress stroke on Awareness, never in the document. Presence is the opposite kind of state from ink: it is only true right now, and writing it to disk means restoring ghosts. Putting progressive remote strokes on the same ephemeral channel is what keeps a remote pen attached to the line it is drawing, while the document still takes exactly one operation when the stroke finishes.
DECISION 04
Exactly one machine, declared in fly.toml. Hocuspocus keeps each document in the memory of the process serving it and SQLite is a local file, so two machines would each hold their own copy of the same board and neither would see the other's edits. Everyone would look connected and silently diverge, which is worse than an outage because nobody notices. Horizontal scaling needs a shared backend — Redis pub/sub — before it needs a second machine.
DECISION 05
Auto-keep for a recognised shape gated at 0.85 confidence. Above the threshold, doing nothing keeps the clean shape; below it, doing nothing keeps your stroke untouched. The bug that set the threshold was found while recording the demo, where a hand-drawn zigzag was silently replaced with a straight line. Straightness is now measured globally, and the default on low confidence is to leave the user's work alone.
DECISION 06
The latency figure published as measured, with the conditions attached. 299 ms deployed is a true number that mostly describes a free tier, and quoting only the 7 ms LAN figure would describe a laptop. Both ship, with the ~285 ms of CPU throttling isolated by measurement rather than asserted — a route doing no database work costs the same, and a held-open socket ping round-trips at 322 ms against a 25 ms network. Publishing the bad number with its cause attached is the whole point of the repair.
== </what i learned> ==
Run the product before you read it. The worst defects here were visible in the first two minutes of clicking, and none of them were visible in the forty markdown files describing the codebase.
A number nobody measured is a liability, not a feature. The "50–80 ms sync" line survived because it was never checked, and measuring it did not confirm it — it retired it from both directions at once.
In a CRDT the interface is not a security boundary. Every connected client holds a writable handle on the shared document, so a permission model that lives in the toolbar is a suggestion; the check has to sit on the connection.
Deploying found a bug that nine sprints of local work could not. The New Session button was clickable before the socket had connected — a few milliseconds of exposure on localhost, about a second over the internet, and a click inside that window did nothing on a button that looked ready.
Measure the hosting apart from the software and a bad number becomes publishable. 299 ms is a true figure that mostly describes a free tier; isolating the ~285 ms of throttling is what lets it be published as measured instead of quietly withheld.
A paid machine and a clean re-measurement — roughly $3–4/month — because 299 ms describes Fly's trial tier and not this code. Then the bar this project set itself and still has not cleared: two physically separate machines, rather than two browser contexts on one laptop. And a regression test for the connect-gating bug found on deploy, which needs a component-level harness the project does not currently have.
react · yjs · hocuspocus · socket.io · sqlite · express · canvas · vite
== </what the audit found> ==
The work started by running the product instead of reading it, and the product did not work. Clicking New Session assigned you the role VIEWER on the board you had just created, so you could not draw on your own whiteboard. Presence read ONLINE (0) while the socket reported connected at a 2 ms ping. Every board was a `const sessions = new Map()` on the server, deleted outright when the last user left — under a boot banner that headlined "Persistence". The repository carried forty markdown files, fourteen of them reports on testing (ten TEST_REPORT_*, four VERIFICATION_REPORT_*), against zero tests. It also carried a "50–80 ms sync" figure with nothing behind it. That audit is committed as RESEARCH-CONTEXT.md rather than summarised after the fact.
== </what was repaired> ==
The two headline blockers turned out to be one race condition. The client subscribed to socket events only after the server had already broadcast the joined-user message, so the one packet carrying roles and presence arrived before anyone was listening — while the acknowledgement the client did receive already held the correct answer and was being thrown away. Seeding state from the ack removed the race by construction, and that is now pinned by an integration test against a real server over a real socket. Repairing it exposed what was underneath: cursor-move was never emitted by the application at all, so remote cursors could not have worked; the session id was truncated in the one place it was displayed, so nobody could ever join a board; the canvas bitmap was sized from the window rather than its own box, so every stroke landed offset from the pointer, and it measured 1440 px wide inside an 870 px visible area, so you could draw where you could not see. State then moved out of the in-memory Map into a Yjs document persisted to SQLite, and the delete-the-session-when-empty branch went with it. Thirteen client events that had been emitting into the void — layers, templates, smart shapes, text formatting, video embeds — got real handlers on the document, and API.md is now a contract with no documented-but-missing and no emitted-but-unhandled events. Writing the first tests found seven more bugs, the sharpest being corner detection so inverted that a 48-point rectangle scored 46 corners, which meant rectangle, triangle and diamond recognition could never fire on a hand-drawn stroke. Recording the demo found the last one: a hand-drawn zigzag was silently replaced by a straight line, because straightness was measured locally and a smooth wave is locally straight everywhere.
== </what was measured> ==
Sync latency is measured as the time from one person committing a finished stroke to that element existing in another browser's document — the CRDT update, the relay, the remote apply and the render — deliberately not a socket ping, because conflating the transport with the product is how the original unbacked number got written down. Thirty samples per environment: 8 ms p50 on loopback, 7 ms p50 / 16 ms p95 over LAN, and 299 ms p50 against the deployment. Almost none of that 299 ms is the application, and the budget was taken apart rather than assumed: the network round trip to the Fly region is ~25 ms, the app's own cost is the 7–8 ms above, and the remaining ~285 ms is free-tier CPU throttling — isolated by showing that GET / does no database work and still costs the same as GET /health, and that over a held-open socket, where the machine cannot be idle, a trivial ping round-trips at p50 322 ms. Persistence was proved by killing the server process, starting a new one, and getting the board back into a browser profile with an empty IndexedDB. The benchmark reports the recovered ink as a pixel count, which is deliberately NOT published here: it is a rasterisation artifact of a synthetic stroke at a fixed viewport, so any change to stroke width or colour moves it, and it did — the figure quoted during the build no longer matches what the same script prints today. What is stable, and what the claim rests on, is that nothing is lost. The permission boundary was driven at the wire with a valid viewer token and no UI involved, plus two forged tokens refused. Remote cursor interpolation was verified as time-based rather than frame-based, converging within 383/375/400 ms at 30/60/144 Hz. Moving the app off localhost was verified across a LAN with only environment variables changed and no source edit.
== </decisions> ==
DECISION 01
Yjs and Hocuspocus for the document, over Redis or Postgres
because A CRDT answers persistence, conflict resolution and offline editing in one architectural move rather than three, and it is what production whiteboards actually use. The cost is a modelling discipline that is easy to get wrong: a stroke is inserted as one operation holding an immutable point array, never one operation per sampled point, or a minute of drawing produces thousands of operations and the history can never be compacted. That rule is a test, not a comment.
DECISION 02
Roles enforced at the document connection, never inside the CRDT
because A CRDT hands every connected client a writable handle on the shared type — including any field describing their own role — so anything stored inside the document is editable by anyone who can reach it, and hiding the toolbar from a viewer is decoration. Roles live in SQLite behind the control-plane socket, and the document connection consults them before it hands over a writable document.
DECISION 03
Cursors, camera and the in-progress stroke ride Awareness, never the document
because Presence answers who is here now; persisting it would mean restoring ghosts on every reopen. Putting progressive remote strokes on the same ephemeral channel is what keeps a remote pen attached to the line it is drawing, while the document still takes exactly one operation per finished stroke.
DECISION 04
Exactly one machine, declared in fly.toml with the reason written above it
because Hocuspocus keeps each document in the memory of the process serving it and SQLite is a local file, so two machines would each hold their own copy of the same board and neither would see the other's edits. Users would look connected and silently diverge, which is worse than an outage because nobody notices. Horizontal scaling needs Redis pub/sub first, and that is a different project.
DECISION 05
Auto-keep for a recognised shape gated at 0.85 confidence
because Above the threshold, doing nothing keeps the clean shape; below it, doing nothing keeps your stroke. The failure mode found while recording the demo was the tool silently replacing a hand-drawn zigzag with a straight line, and silence should never cost a user their work.
DECISION 06
"AI shape completion" renamed to shape recognition
because It is geometric heuristics — corner detection, closure, convexity, roundness — and never was machine learning. Stated accurately it is more interesting than the label, and the label would not have survived one interview question.
== </where it doesn’t work> ==
It is live, and it is hosted on a free tier that is doing most of the damage: end-to-end sync measures 299 ms p50 against the deployment, of which roughly 285 ms is CPU throttling rather than the software. fly.toml asks for a machine that never stops, and the trial tier stops it anyway after five minutes idle, so the first visitor after a quiet spell waits about six seconds while it boots — the interface says so while it happens instead of showing a silent spinner. Every measurement here, the deployed row included, runs both browser contexts on one laptop, so the bar this project set itself — two people on different machines — is still not cleared. There is no authentication of any kind: the client id identifies a browser, not a person, and anyone holding a session id can open that board. Offline caches the document but not the app shell, so there is no service worker and reloading the page while offline still fails at the network; the synchronised camera is published over Awareness and nothing consumes it; and the browser acceptance gates are run by hand rather than in CI, because they need a browser image the pipeline does not have. An older "50–80 ms sync" line on this site is withdrawn rather than deferred — it was unbacked before and it is contradicted now, at 7–8 ms locally and 299 ms deployed.
LIVE · MIT · FREE-TIER HOST · collab-frontend-omega.vercel.app