Scalidraw is a local-first Excalidraw distribution with a cloud I run myself. Drawings live in the browser’s IndexedDB. Every change is pushed as an end-to-end encrypted op to a shoal server, and a new device restores the library from a 12-word BIP39 phrase. The server stores ciphertext it cannot read, and the static app on Vercel holds nothing.
architecture
Excalidraw already ships stable ids, version counters, nonces, and tombstones, so per-element last-writer-wins is nearly free.
markSeen runs before updateScene, so the onChange that updateScene triggers diffs to zero. that one ordering rule replaces version-vector bookkeeping. images travel as single 4 MiB ops, content-addressed and written once.
Excalidraw is used as a package through its imperative API, two calls in total: updateScene and addFiles. Its onChange fires constantly, so a trailing 400 ms debounce snapshots the elements. Each element’s (version, versionNonce) pair is compared against a seen map. Excalidraw bumps both on any mutation, so inequality is exactly “changed”, and a selection or viewport change diffs to nothing.
One collection holds three record kinds: drawing/<id> for title and timestamps, el/<drawingId>/<elementId> for the full element JSON, and file/<fileId> for binary file data, content-addressed and written once. Unknown prefixes parse to null for forward compatibility.
Sync is one ShoalSync instance keyed on server URL plus mnemonic, over a Dexie adapter. Push drains the outbox to POST /v1/ops, pull is GET /v1/ops?since=N, and the poke stream is read through fetch because EventSource cannot send signature headers. Remote ops go through shoal-client’s last-writer-wins gate. The winner is written into Dexie, marked as seen, and then applied to the scene. Deletes are Excalidraw’s own isDeleted tombstones kept as rows, and ordering rides on each element’s fractional index, so there is no ordering record.
technical decisions
- Package, not fork. Only two API calls are used. The whole integration cost is one Vite
definefor a build flag Excalidraw reads at module scope. - Per-element last-writer-wins over a CRDT. Excalidraw already ships stable ids, version counters, nonces and tombstones, so LWW is nearly free. A whole-scene record would blow the payload cap on the first image and make every concurrent edit a whole-drawing conflict. The accepted trade is no realtime co-editing.
- Debouncing. 400 ms on push, 150 ms on apply. Renames commit once on confirm, not per keystroke.
- The echo guard is an ordering rule.
markSeenruns beforeupdateScene, so theonChangethatupdateScenetriggers diffs to zero. No version vectors. - Cursor reset whenever the server URL or identity changes, because the cursor indexes one specific server’s log.
- Static deploy. A Vercel static build, deployed by hand. The server sits behind Tailscale Funnel with a public-key allowlist.
hard problems
- Echo suppression without version-vector bookkeeping.
- Images as single 4 MiB ops instead of a chunked transfer, with the cap enforced on both sides.
- Offline drain through the IndexedDB outbox with exponential backoff, plus refocus and online triggers.
- SSE authentication under signed requests.
- Chrome’s secure-DNS hairpin on the server machine itself, solved by talking to
127.0.0.1there.
numbers
| measure | value |
|---|---|
| payload cap | 4 MiB per op |
| debounce | 400 ms push, 150 ms apply |
| compaction | every 24 h |
| source | about 1,600 lines, two test files |