selfstore vs Yjs
TL;DR: Not rivals, and the pairing is the point: Yjs merges concurrent edits losslessly, selfstore gives that document an encrypted home the user owns and no server. Storing Yjs updates in a selfstore file is a supported, tested pattern.
What Yjs is
The CRDT that won. A high-performance implementation of shared types - text, arrays, maps - that merge without a coordinator and without losing a keystroke, under the hardest case there is: two people typing in the same paragraph at the same moment. It is what sits under a great many collaborative editors, and if your problem is concurrent editing, nothing here competes with it.
What Yjs deliberately does not decide is everything around the document.
Persistence is a provider you choose (y-indexeddb), transport is another
(y-websocket, and a server to run it), and encryption at rest, a backup the
user can hold, a destination they own and the screens to connect one are simply
not in scope. That is a healthy design. It also means a Yjs app still has the
whole storage question in front of it.
What selfstore is
That question, answered, with no server: an IndexedDB working copy, portable encrypted ZIP backups on an open spec, durable homes on storage the user already has, and deterministic multi-device merge.
Its own merge is last-write-wins, which is honestly the wrong answer for a paragraph two people are typing in. So do not ask it to be: keep Yjs for the document, and let selfstore carry it.
They compose, and here is why that works
selfstore merges binary files by a union on their id. Yjs updates are commutative and idempotent - apply them in any order, apply one twice, you land on the same document. Store each update under the id of its own bytes, and the union is the CRDT merge: no device’s update is lost when the copies meet, and folding them is the whole read path.
const doc = new Y.Doc();
const fold = () => {
for (const f of store.allFiles())
if (f.mime === UPDATE_MIME) Y.applyUpdate(doc, f.bytes, FOLDED);
};
fold(); // what this device holds
store.onChange(fold); // and what arrives from another
doc.on('update', (u, origin) => {
if (origin !== FOLDED) void store.putFile({ bytes: u, mime: UPDATE_MIME });
});
putFile defaults a file’s id to the SHA-256 of its bytes, which is what makes
this safe: a single stable id for the whole document would put two devices’
different bodies under one key, and a union has no clock to order them - one
would be dropped in silence. The library refuses that by default.
| Yjs | selfstore | |
|---|---|---|
| Solves | Concurrent edits, losslessly | Durability, portability, destinations |
| Merge | Real CRDT | Last-write-wins per record |
| Live collaboration | Yes, with a server | No |
| Local persistence | A provider you add | Built in |
| Encryption | Yours to build | Built in, AES-256-GCM + Argon2id |
| User-holdable backup | Yours to build | First-class, spec’d ZIP |
Use Yjs alone when
- You have a collaboration server anyway, and storage is already solved.
- The document is the whole app and it never leaves the session.
Use both when
- You want real concurrent-edit merging and the file the user walks away with, end-to-end encrypted, on their own Drive or disk, with nothing hosted.
The complete integration - compaction pass and its honest limit included - is
examples/yjs-document.ts.