Build Excalidraw+
A collaborative whiteboard where the difficulty is entirely in concurrent editing of a shared document: reconciling simultaneous changes without a central authority deciding who loses, keeping cursors live without persisting them, and encrypting scenes end to end, which then forbids every server-side feature you would otherwise want.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the socket server and builds the client.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - have readyfree
Why Every claim about collaboration is untestable with a single client, and two tabs in one browser hide timing problems that two machines expose.
Get it A second machine, or at minimum two different browsers with the network throttled differently.
Verify
Two clients can join one room and both see each other's changes - have readyfree
Why Delta transmission, snapshot cost and canvas performance all behave differently at five hundred elements, and a test scene of ten proves nothing.
Get it Draw one, or generate a scene with several hundred varied elements including freehand paths, which are the expensive ones.
Verify
A scene with five hundred elements including freehand paths exists - decidefree
Why End-to-end encryption forbids server-side search, thumbnails, export and key recovery, and retrofitting it later means removing features people already use.
Get it Decide, and write down what you are giving up in exchange. Both answers are defensible; discovering the trade halfway is not.
Verify
A written note states the decision and lists the features it forecloses
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- scenes(id, encrypted_blob, iv, version, updated_at) · the server sees ciphertext and nothing else - elements(id, type, x, y, width, height, angle, points_json, style_json, version, version_nonce, updated_by, deleted) - Elements live inside the encrypted scene. The server schema above is what a self-hosted unencrypted mode would use - rooms(id, scene_id, created_at, last_active_at) · a live session, holding no content - presence(room_id, client_id, name, colour, cursor, selection) · in memory only, never written to disk - A deleted element is a tombstone with a version rather than a removed row, because a delete and a concurrent edit must reconcile.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/canvas | Encrypted scene blobs and room records. Contains no readable content by design. |
SOCKET_PORT | required | 3001 | The live collaboration channel, separate from scene loading over HTTP. |
PRESENCE_THROTTLE_MS | required | 50 | How often cursor positions broadcast. Lower feels smoother and floods the channel. |
PRESENCE_TIMEOUT_SECONDS | required | 30 | When a silent client's cursor is removed. Clients vanish without disconnecting. |
SNAPSHOT_DEBOUNCE_MS | required | 2000 | How long after the last change a snapshot is written. Per-change does not scale. |
MAX_SCENE_BYTES | required | 10485760 | A cap, since the server cannot inspect an encrypted scene to judge whether it is reasonable. |
The build, in order
A canvas with versioned elements
Drawing works locally and every element carries what reconciliation will need.
A counter and a random tiebreak. Both are needed for a deterministic resolution.
Files
src/element.tsA removed row cannot reconcile against a concurrent edit.
Files
src/element.tsA reload mid-drawing must lose nothing.
Files
src/persist.ts
done when · tick each as it passeswatch out- Deleting an element by removing it from the scene makes a delete concurrent with an edit unresolvable, because the client that edited it simply reinserts it and the element comes back from the dead. Tombstone with a version, and let the normal reconciliation rules decide.
Two people drawing at once
Simultaneous edits from two clients resolve identically on both.
Two elements in, one out, deterministic. Unit test with conflicting pairs.
Files
src/reconcile.tssrc/reconcile.test.tsBoth clients must reach the same answer without consulting the server.
Files
src/reconcile.tsWhole-scene broadcast collapses at a few hundred elements.
Files
src/sync.ts
done when · tick each as it passeswatch out- Sending the entire scene on every change works beautifully with two elements and collapses at five hundred, saturating the connection and making the canvas lag behind the pointer. Send only what changed, and make reconciliation a pure function you can test with two conflicting element pairs directly.
Cursors, without drowning the connection
You can see where other people are, and it costs almost nothing.
Its own channel, a fixed rate. Drawing must never queue behind pointer noise.
Files
src/presence.tsIt is worthless a second later and it is a record of what people looked at.
Files
src/presence.tsClients vanish without disconnecting. Ghost cursors accumulate otherwise.
Files
src/presence.ts
done when · tick each as it passeswatch out- Sending a cursor position on every pointer move puts hundreds of messages a second on the same channel as element changes, and the drawing updates end up queued behind pointer noise. Throttle presence, put it on its own channel, and drop it rather than buffering it when the connection is congested.
Encrypted scenes, and living with the consequences
The server stores scenes it cannot read, and you accept what that forbids.
The server should never be able to read a scene, even accidentally.
Files
src/crypto.tsFragments are not sent in requests. Verify that in server logs, do not assume it.
Files
src/share.tsSearch, thumbnails, recovery. Decide each now rather than discovering it later.
Files
docs/encryption-tradeoffs.md
done when · tick each as it passeswatch out- End-to-end encryption removes server-side search, thumbnails, exports, moderation and any recovery of a lost key, and each of those tends to be discovered as a surprise halfway through a later feature. List them at the start: if the key is lost the scene is gone, and there is no reset.
Saving, history and exportproduct builder
Scenes persist, past versions can be recovered, and drawings leave as files.
Per change is untenable once a scene is large.
Files
src/snapshot.tsDecryption happens in the browser, which means history browsing does too.
Files
src/history.tsThe server cannot render what it cannot read. This follows from phase 4.
Files
src/export.ts
done when · tick each as it passeswatch out- Snapshotting the whole encrypted scene on every change writes megabytes per stroke once a drawing is substantial, which is slow for the client and expensive in storage. Snapshot on a debounce and on meaningful boundaries, and prune history rather than keeping every version forever.
That is the whole plan for Excalidraw+. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- Offline editing with later reconciliation.
- Server-side rendering, search and thumbnails.
- Shape recognition and beautification.
- Comments, permissions and team libraries.
- large template libraries
- high-fidelity import and export
- presentation and enterprise controls
- multiplayer cursors and comments
- Frames and named sections, which is how a large board stops being unusable
- A read-only share link, which needs a second key rather than a permission flag
- Embedded images, once the scene size and snapshot costs are understood
Need the files? The project pack on the verdict page hands your agent the whole brief · more whiteboards.