•
4 min read
the sync echo that never happens
scalidraw sync react local-first

Scalidraw is Excalidraw used as a package, with two API calls, plus a self-hosted encrypted cloud. Drawings live in the browser’s IndexedDB, every element-level change becomes an encrypted operation pushed to a shoal server, and a new device restores the whole library from a 12-word phrase. The static app holds no data and no secrets.

The bridge between the two is small, and it has one problem that every sync integration with a live-editing component has.

the loop

Excalidraw fires onChange on every mutation of the scene. The sync layer listens to onChange to find local edits and push them. When a remote update arrives, the sync layer applies it with updateScene, which is a mutation, which fires onChange, which the sync layer sees as a fresh local edit and pushes back to the server. The server delivers it to the other device, which applies it, which fires its own onChange. Two devices can ping the same element back and forth forever.

The usual fix is bookkeeping: tag each change with an origin, or carry a version vector, or suppress onChange during apply with a flag. The flag approach fails on the first asynchronous render. The bookkeeping approaches work and cost a data structure.

one ordering rule

The sync layer keeps a seen map: for every element id, the (version, versionNonce) pair it last synced. Excalidraw bumps both fields on any mutation to an element, so inequality against the seen map means exactly “changed since sync”.

The remote apply path does two things, in this order:

markSeen(seen, incoming.elements);   // first
api.updateScene({ elements });       // second

Because the seen map already contains the incoming elements’ versions when updateScene fires onChange, the change handler diffs every element against the map and finds nothing changed. Nothing enters the outbox. The echo never happens, and there is no flag to forget to clear and no origin tag to carry.

The local path is the complementary half. A trailing 400 ms debounce snapshots the scene and compares each element against the seen map. Changed elements are encrypted and pushed, and the map is updated. A selection change or a viewport pan does not bump any element’s version, so it diffs to nothing and costs nothing.

The rule only holds if the seen-check runs strictly before the mutation that would re-trigger it. That is stated in the file’s header comment, because it is the kind of invariant a refactor breaks by reordering two lines that look independent.

per element rather than per scene

Records are per element, keyed by drawing and element id. A whole scene as one record would exceed the sync server’s payload cap on the first pasted image, and it would turn every concurrent edit on two devices into a whole-drawing conflict resolved by whichever wrote last.

Per-element records make ordering a question. Excalidraw answers it for free: element order is stored as a fractional index on the element itself, so a reorder is an element change like any other. Deletes are Excalidraw’s own isDeleted tombstones, kept as rows, so a delete syncs as an update.

Images are single content-addressed ops of up to 4 MiB, written once and referenced by hash. The cap is set deliberately to the same value on the client and the server so a file that fits one fits the other.

what was declined

A CRDT or a real-time co-editing layer, such as the one Excalidraw’s own hosted service uses. Declined with the trade named: no live cursors and no simultaneous co-editing. This is one person across their own devices, and last-writer-wins per element is the right amount of conflict resolution for that. Renames commit once on confirm rather than once per keystroke, and apply is debounced at 150 ms against push at 400 ms, so a burst from the other device settles before the local snapshot runs.

The architecture diagram on this site’s case study page was drawn in scalidraw and synced through the pipeline it describes. About a thousand lines of TypeScript, three commits, and a drawing tool I can open on any machine.