scalidraw

excalidraw with a self-hosted encrypted cloud

excalidraw as a package, drawings in indexeddb, and every change pushed as an end-to-end encrypted op to a shoal server. a new device restores the whole library from 12 words. the static app holds no data and no secrets.

react 19, typescript, vite, @excalidraw/excalidraw, dexie, shoal-client, vercel

a hand-drawn architecture diagram open in scalidraw
a local build of scalidraw, with the shoal architecture diagram from this site pasted in

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

how does an Excalidraw edit become an encrypted op, and how does a remote op come back without echoing?

Excalidraw already ships stable ids, version counters, nonces, and tombstones, so per-element last-writer-wins is nearly free.

stored state
Excalidraw npm package diff version + versionNonce records drawing/ el/ file/ Dexie elements + outbox markSeen, then updateScene applyRemote per-element LWW shoal-client encrypt, push, pull shoal server onChange, 400 ms changed elements write outbox drain push ciphertext,pull ops, wait for poke remote op no echo

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 define for 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. markSeen runs before updateScene, so the onChange that updateScene triggers 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.1 there.

numbers

measurevalue
payload cap4 MiB per op
debounce400 ms push, 150 ms apply
compactionevery 24 h
sourceabout 1,600 lines, two test files