Clusker

Real-time editing where the file is still the original

How we added live collaboration to documents that live as markdown files: four releases ordered by risk to your data, Yjs, and no separate room server.

Documents in Clusker are markdown files, and that decision stands: the file is the original, the blocks in the database are a cache, sync runs both ways, and conflicts are never merged automatically. You can change a page in another editor and Clusker picks it up.

Real-time collaboration pulls the other way. Two people typing on one page need a shared state that merges keystrokes, and that state can't be a file rewritten on every character.

A boundary in time

We didn't abolish the rule; we gave it a boundary in time. While a document is closed, the file is the original. While it is open for collaboration, the original is a shared Yjs room, and the file receives snapshots.

If someone edits the file from outside while the room is open, we don't try to merge a CRDT with text from another editor. Clusker marks a conflict, the room goes read-only, and everyone in it sees the choice.

Four releases, ordered by risk to your data

Splitting this work by layer would have put the most dangerous change up front, so we split it by what you would lose if a release broke.

  • First, a canvas with no room. One writer, saved through the existing endpoint. Rolling the code back leaves the file byte-for-byte the same.
  • Second, a room with no shared state. People on the same page see each other's faces in the header, and not one byte of content goes through the room. If it breaks, you lose avatars, not text, while the connection, permissions and limits get tested on real traffic.
  • Third, the canvas on top of the room. A good first carrier of shared state: self-contained JSON, no editor history, no hard questions about undo.
  • Fourth, text. The riskiest part, and the last, because this is where typing can break.

Yjs, yes. A room server, no.

Shared state runs on Yjs, and sync uses y-protocols, the protocol every Yjs server speaks. Writing our own would have created a second dialect of one protocol. What we didn't adopt is Hocuspocus, the usual server for Yjs rooms.

By then, everything a room server is brought in for was already in production: a WebSocket route inside our Fastify app, session and Origin checks, eviction, limits and orderly shutdown. Hocuspocus would have added its own authentication, lifecycle and addressing, with one socket carrying many documents. Ours is one document, one address, permissions checked before the upgrade.

The room lives in the same process, on the same port, behind the same nginx. No extra container, daemon or Redis.

The order of writes

room (Y.Doc) → state snapshot in the database → blocks → markdown file
  • An edit reaches the room immediately.
  • Room state goes to the database with an 800 ms debounce, the same number as regular autosave.
  • Blocks and the file follow every 5 seconds and when the room closes. A watcher looks at that file, so it isn't rewritten on every keystroke.

One safeguard had to move. The check for outside changes to the file lived in the HTTP route, which the room bypasses, so it moved into the shared write function. Otherwise a snapshot landing between an outside edit and the watcher noticing it could erase that edit without a trace.

One writer at a time

AI agents over MCP use the same API as the app. So while a room is live, writing blocks around it returns 409 room_open. Markdown from an agent can't be merged into a CRDT, and silently winning over a person who is typing right now would be worse. The file watcher follows the same rule and marks a conflict instead.

Undo undoes only yours

On the canvas, ⌘ Z used to restore a snapshot of the whole canvas. In a shared room that snapshot includes your neighbour's nodes, so restoring it would undo their work along with yours. Inside a room, undo is now a Y.UndoManager tied to the origin of your own edits, on the canvas and in text alike.

Two holes the plan didn't name

A tab's Yjs document outlives its socket, so offline edits are sent on reconnect. If the room had been rebuilt from blocks in the meantime, merging would have doubled the canvas. Rooms now carry a generation mark, and a tab that sees a new one takes the server's state.

The second only showed up in a live run. The browser sends its first sync message as soon as the socket opens, while the server was still loading the document and not yet listening. Both sides then waited for each other. Early frames are now queued.

What we have checked, and what we haven't

Two people now see each other's faces, letters as they're typed, each other's caret in its own colour with a name, and a node moving while it is still being dragged. The name on a caret comes from the server, not the client, so it can't be someone else's.

We tested this on a smoke-test server with two WebSocket connections, on the canvas and on text pages. The canvas run also covered the 409 refusal, blind edits after a dropped connection and conflicts from an outside file edit. We haven't yet tried it with two people in two real browser windows. That is the next check, and until it passes we won't claim more than this.

Keep reading

All posts

Changelog4 min read

What shipped in early September

A graph view for your docs, shared editing in pages and canvases, comments that read markdown, and seven fixes from our own review.

Company4 min read

Why we don’t charge per person

One price for the whole team. Plans climb with active projects and storage — never with the number of people you invite.

Design4 min read

We deleted 29 of 55 captions on mobile

Our interface had started explaining itself in small grey text. In one review we removed most of it, and rewrote the rules that had put it there.