MindMapVault is an end-to-end encrypted mind mapping tool. Maps are encrypted in the browser with a key derived from the user's password; the server stores ciphertext and has never held a key.
That design makes one feature genuinely hard: two people editing the same map at the same time. This is how we built it anyway, on Cloudflare, and the parts where the obvious approach had to be thrown away.
Why the standard approach does not work
For collaborative editing, the CRDT you reach for is Yjs.
Two people edit concurrently, both documents converge, no locks and no operational transform server. Yjs has ready-made backends — y-websocket, and y-durableobjects for Cloudflare specifically. Wire one up and you have collaboration in an afternoon.
We could not use any of them, and the reason is one sentence in the Yjs sync protocol: the server computes state vectors and diffs.
When a client connects, the standard protocol has it send a state vector — a summary of what it already has — and the server replies with exactly the updates that client is missing. That is an efficient design and it requires the server to parse the updates. A server that can parse Yjs updates is a server that can read the document.
For most products that is fine. For this one it defeats the entire premise. So the sync protocol had to be replaced with one where the server understands nothing.
The protocol
Every frame is one type byte and an opaque payload. The payload is AES-256-GCM
ciphertext produced with a key the server has never seen.
UPDATE client → room → everyone else an encrypted Yjs update, appended to a log
UPDATE_TAGGED room → everyone else the same, with the sender's id in front
SNAPSHOT client → room an encrypted consolidated state; replaces the log
AWARENESS client → room → everyone else cursors and selections; relayed, never stored
SYNC_REQUEST client → room "I am listening, send me the log"
SYNCED room → client "that is all of it"
RESYNC room → client "somebody compacted; drop what you have"
The room appends UPDATE payloads to a log and fans them out. It never merges anything, because it cannot. A client joining receives the entire log and replays it locally; Yjs converges regardless of order, so this works, but the log obviously cannot grow forever.
Compaction is therefore client-side. Periodically a connected client encodes the whole document as one update, encrypts it, and sends SNAPSHOT. The room replaces its log with that single blob in one transaction, keeps a copy as a checkpoint, and tells everyone else to resync. The server has performed a garbage collection on data it cannot read, by being told the answer.
This is the central trade. The client does the reconciling that a normal collaborative server would do for you. In exchange the server is a dumb pipe that a breach would yield nothing useful from.
The Cloudflare parts, and why each one earns its place
Durable Objects: one room per map
A Durable Object is a single-threaded, globally-unique, addressable object with its own storage. One per map, named by the map id:
const room = env.MINDMAP_ROOM.getByName(mapId);
Every client for a given map reaches the same instance, anywhere in the world. That single sentence removes an entire category of infrastructure: no Redis for fan-out, no lock service, no leader election, no sticky sessions. The room is the coordination point, and it is the only one.
The object holds its own SQLite database, so the encrypted update log lives with the object that serves it rather than in a shared database everything contends for.
WebSocket hibernation: idle rooms cost nothing
The natural worry with an object-per-map is cost. A team leaves a map open over lunch and you are paying for an idle process for an hour.
Hibernation
solves it: the runtime evicts the object from memory while the WebSockets stay open, and revives it on the next frame. You are billed for work, not for waiting.
The catch is real and shapes the code: instance state does not survive hibernation. Anything in a class field is gone between frames. So per-connection state lives on the socket's own attachment:
server.serializeAttachment({ mapId, userId, username, colo,
windowStart: Date.now(), framesInWindow: 0 });
Our per-connection frame budget lives there for exactly this reason. A rate limiter kept in a field would silently reset every time the room went to sleep — which is to say, it would not be a rate limiter.
D1: identity, membership, key envelopes
Content is unreadable, but there is metadata a server necessarily has: who exists, who is a member of which map, and each member's wrapped key.
The map key is wrapped separately per member using a hybrid KEM — X25519 and ML-KEM-768 together, so a recording-now-decrypting-later attacker has to break both. D1 stores the envelope; only the member's own private key opens it, and that private key is itself wrapped under a key derived from their password with Argon2id.
R2: durability that needs no key
The room copies its log to R2 hourly. There is nothing to encrypt on the way out — the log already is ciphertext, and the object has no key to add another layer with even if that helped.
Rate Limiting, Analytics Engine, Cron
Native rate limiting bindings on sign-in and invitations. Analytics Engine for delivery metrics — event names, ids, durations, never content. A nightly cron to purge soft-deleted maps.
Each of these would be a service to run. Here they are four lines of config.
Five things that were not obvious
Sync has to be client-initiated. The first version pushed the backlog the moment a socket opened. Across a service binding, frames sent before the client's own listeners are attached are simply lost. One extra round trip — SYNC_REQUEST — removes the race entirely and lets the client decide when it is
ready.
A Yjs type can be integrated in exactly one place. Duplicating a node copied its fields, and one of those fields was a Y.Text holding the node's notes. Setting the source's own instance onto the copy produced a document that looked perfectly correct locally and an update that threw inside Yjs on every other machine. The local editor was the one place the bug was invisible. The fix is
one word — value.clone() — and the lesson is the test:
const remote = new Y.Doc();
Y.applyUpdate(remote, Y.encodeStateAsUpdate(doc)); // must not throw
Assert that an update still applies on a peer, not just that your own document looks right.
Compaction that counts the wrong events never fires. Ours triggered on updates received. A room with one person in it receives nothing, so it never compacted, and a solo editing session grew its log without bound until somebody else happened to join. Own edits count now.
A fixed backup key is not a backup. Writing every hourly snapshot to latest.log means a corrupted log is faithfully copied over the last good copy within the hour. Date the key, keep N, prune.
Authorization checked at connect is not checked again. Removing a member deleted their row and their key envelope, and their still-open WebSocket carried on receiving every edit until they closed the tab. Removal has to close the socket explicitly.
What this architecture genuinely gives up
Being honest about this is more useful than a list of wins.
Metadata is visible. The server knows who is connected to which map, when, and how much traffic there is. It cannot see a title or a single character of content, but the shape of a collaboration is not hidden.
The client does more work. Joining means replaying a log rather than receiving a computed diff. Compaction keeps that log short, but a client that joins a busy room does more than it would against a server that could merge.
No server-side search, no server-side anything. Every feature that would normally be "add an endpoint" is either a client feature or does not exist.
Removal is not retroactive. No re-keying on member removal in v1. What someone already decrypted, they have.
Was it worth it
The feature is indistinguishable from an ordinary collaborative editor when you use it. Cursors move, text merges character by character, someone joins late and sees everything.
The difference only shows when something goes wrong somewhere else. What an operator can hand over is what an operator holds, and here that is encrypted blocks and the knowledge that a session happened.
Cloudflare's primitives are what made the trade affordable. A Durable Object per map with hibernation gave a single coordination point per document that costs nothing while idle — which is precisely the shape a per-document collaboration server wants, and precisely the thing that is tedious to build and operate yourself.
MindMapVault is an end-to-end encrypted mind mapping tool.
Live collaboration is in testing and ships to Cloud accounts alongside SSO — the
announcement for people who use it, rather than build it.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.