Compared with CRDT libraries
Yjs, Automerge and Loro are the CRDT libraries JavaScript apps reach for when several people edit the same thing. syncom merges with CRDTs too: every field of a record is a register, a counter, a set, a list, a text or a tree, and every node merges them the same way. This page compares the four as CRDT libraries only: the data types and how they merge, history and versions, encoding and sync, integrity, and what a developer works with. It leaves out what syncom has as a database (schemas over collections, queries, indexes, a storage engine) and what the three leave to you; Compared with alternatives covers that, next to the databases and sync services.
The three differ among themselves too. Yjs is pure JavaScript, small, fast, and has the most editor bindings. Automerge is a Rust core compiled to WebAssembly, and keeps every change of a document in a hash-linked history. Loro is a Rust core compiled to WebAssembly too, with the most container types (a movable list, a movable tree, rich text) and a document that loads lazily. The table below compares all four, row by row, from their source code: 141 rows in seven sections.
What the table shows
- Three document CRDTs and a record CRDT. A Yjs, Automerge or Loro document is one tree of containers, nested to any depth: a list of maps of texts. A syncom replica is a storage of records, and each record is a set of top-level fields, each of one kind. A nested object is one register, replaced whole, and a list holds plain JSON values. Structures are joined by id instead: the blocks of a long document are records in a list, and the nodes of a tree are record ids. Most of the rows where syncom has nothing and the others do follow from this: nested containers, containers inside a list, embeds and block markers in a text, an XML type.
- The same families of merge rules. syncom's lists and texts use Fugue (Weidner and Kleppmann, 2023), as Loro's do. Yjs uses YATA, and Automerge uses RGA. In syncom and Loro two writers' runs never interleave. Automerge interleaves runs typed backwards, and a three-replica case can interleave in Yjs. syncom and Loro have a movable list (
setAt/move) and a movable tree (Kleppmann et al., 2021); Automerge and Yjs have neither, so a move there is a delete and an insert. All four mark ranges of text. syncom and Automerge take the expand rule per mark call, and Loro per key. Only syncom stacks marks of one name, each with its own id and value (comments); the others need a key per comment. - What only syncom has. In twelve rows syncom alone has the feature built in. In four of them none of the other three has it at all: an observed-remove set, named versions (tags of the whole storage), signed changes (Ed25519 per op, the node's id is its key), and validation of a peer's change against a schema and hooks. In the other eight they have part of it: a hook that can veto or rewrite a local change before it commits, refusing a peer's change, purging a payload from the history of every replica it reaches, partial replication by scopes that the host enforces, limits against a hostile peer, overlapping marks of one name, a schema, and errors with codes.
- What syncom does not have. No nested containers, embeds, block markers, XML type or deltas. No editor bindings, UI bindings, presence or awareness, and no bindings for other languages. No readable losers of a conflict (Automerge's
getConflicts), no API to compare versions, no suggestion mode, no cursor saved with an undo step, and no anonymized copy for bug reports. It does not check that an index splits no surrogate pair. Where the others have it whole, syncom has part of it: its TypeScript types type the collections, not the records; a field is removed by rewriting the record; a change is one op on one record; events come per op, not per field; and a fork starts at the present or at a tag, not at any version. - Speed is the largest gap. A syncom op is a signed log entry, and the record it touches is read and written whole. Typing into a text that grows past ten thousand characters runs at about 1 800 keystrokes a second at the end and 400 to 550 elsewhere, in memory. Automerge types at 20 000 to 24 000, Yjs at 30 000 to 525 000, and Loro at 310 000 to 620 000. syncom's texts are for a note, a comment, or one block of a document. A document hundreds of pages long that many people type into at once belongs in one of the three.
- Size depends on what is counted. Yjs ships 28 KB min+gzip, all of it. Automerge and Loro ship about 1.1 MB each, almost all of it WebAssembly. syncom's single entry is 66 KB, and that includes its storage, queries and sync protocol, which the three do not carry.
- History is kept four ways. Automerge keeps every change forever, in a graph of SHA-256 hashes, and never drops any. Loro keeps every change too, until you export a shallow snapshot. Yjs keeps the current state and the ids of what was deleted, not the changes; reading the past needs
gc: falseand a snapshot taken at the time. syncom keeps an op log with retention. It reads any version the log still holds, diffs and restores between versions, and keeps named tags past retention.purge()erases a payload on every replica it reaches. - What a replica does with a bad update. Loro throws and rolls the import back. Yjs throws, but keeps what it decoded before the error. Automerge throws on bytes it cannot decode; when we built a change with an op on an object that does not exist, it panicked, and the document panicked again on its next change. syncom drops and counts what is malformed and never throws for it. It also limits what a peer can make it hold (pending work, run and field sizes, tree depth) and refuses stamps too far in the future. None of the three checks a peer's change against a rule, and none signs one; Automerge's hashes and Loro's and Automerge's checksums detect corruption, not forgery.
- Maturity. Yjs 13 is the stable line most apps run, and its README lists the products built on it. Automerge has been stable since 2.0 (January 2023) and at 3.x since July 2025, Loro since 1.0 (October 2024). The Yjs column describes its
mainbranch, the 14.0 release candidate published as@y/y; theyjspackage on npm is still 13.6.33, and the notes say where it differs. syncom is a pre-release with no users yet.
How the table was made
- From the repositories. Yjs, Automerge and Loro were cloned on 2026-10-23 and read at the commits the Version row names. The code came first: the Rust cores of Automerge and Loro, the JavaScript of Yjs, and their tests. Their documentation repositories came next (
yjs-docs,automerge-docs,loro-docs). Where the documentation and the code disagree, the cell follows the code and a note says so. - Checked by running it. Where a claim was cheap to test, it was tested against the published package:
yjs13.6.33,@automerge/automerge3.5.0,loro-crdt1.16.4. A run counts only where the code it exercises is the same at the cloned commit. Interleaving, surrogate pairs, conflicts, counters, a reused replica id and malformed updates were settled this way. - Packages outside the clones. Editor bindings, network providers, sync servers,
loro-protocolandloro-mirrorwere not cloned. Their cells name them from the official docs, and a note says the code was not read. - CRDT features only. A row is about merging, history, encoding, sync primitives, integrity or the developer surface. Queries, collections and storage engines are on Compared with alternatives.
- The syncom column is this repository, at the version on the page, checked against its tests and by running it.
- The data is in the repository.
docs/comparison-crdt.jsonkeeps every cell with the file and lines it was decided from.pnpm docs:comparisonrenders it into this page. If a cell is wrong, open an issue or edit the data.
Concepts side by side
The closest counterpart of each building block, in each library. The Yjs column is the 14.0 API; 13.6.33 has Y.Map, Y.Array and Y.Text where 14.0 has one Y.Node.
| syncom | Yjs | Automerge | Loro | |
|---|---|---|---|---|
| A replica | open({ path }) | new Y.Doc() | Automerge.init() | new LoroDoc() |
| What merges | a record, by id | the document | the document | the document |
| A map | the record's fields: update(id, delta) | node.setAttr(key, value) | change(doc, d => { d.key = value }) | map.set(key, value) |
| A counter | inc(id, key, n) | — | new Counter(), d.c.increment(n) | counter.increment(n) |
| A set | add(id, key, items), remove() | — | — | — |
| A list | splice(id, key, i, n, items) | node.insert(i, items) | d.list.insertAt(i, ...items) | list.insert(i, value) |
| A move | move(id, key, from, to) | — | — | movableList.move(from, to) |
| A text | splice(id, key, i, n, text), updateText() | node.insert(i, text), delete() | splice(doc, path, i, n, text), updateText() | text.splice(i, n, text), update() |
| A mark | mark(id, key, start, end, name, value) | node.format(i, n, { name: value }) | mark(doc, path, { start, end }, name, value) | text.mark({ start, end }, key, value) |
| A cursor | cursor(id, key, i), index() | createRelativePositionFromTypeIndex() | getCursor(), getCursorPosition() | text.getCursor(i), doc.getCursorPos() |
| A tree | treeMove(id, key, node, parent) | — | — | tree.createNode(), node.move() |
| A change | one call, one op | doc.transact(fn, origin) | change(doc, fn) | doc.commit() |
| Events | change and commit events | observe(), observeDeep() | a patch callback | subscribe() |
| A version | a vector, an event id, a tag | a state vector, a snapshot | heads (change hashes) | a version vector, frontiers |
| The past | get(id, { at }) | createDocFromSnapshot() | view(doc, heads) | doc.checkout(frontiers) |
| A diff | diff(id, from, to) | diffDocsToDelta(a, b) | diff(doc, from, to) | doc.diff(from, to) |
| Back to a version | restore(id, at) | experimental undoContentIds() | — | doc.revertTo(frontiers) |
| A fork | branch() from /branch | Y.cloneDoc(doc) | clone(doc) | doc.fork() |
| Undo | createUndo() from /undo | new Y.UndoManager(scope) | — | new UndoManager(doc) |
| All of it | snapshot() pages | encodeStateAsUpdate(doc) | save(doc) | doc.export({ mode: 'snapshot' }) |
| What a peer lacks | ops past its vector | encodeStateAsUpdate(doc, vector) | saveSince(doc, heads) | doc.export({ mode: 'update', from }) |
| Taking it in | storage.sync (applyRemote()) | Y.applyUpdate(doc, update) | loadIncremental(doc, bytes) | doc.import(bytes) |
| Sync protocol | sync.addNode() / sync.unit() | y-protocols/sync | generateSyncMessage() | the Loro Protocol (loro-websocket) |
The matrix
✅ built in, or in an official package of the same project (named) · ⚠️ partial, opt-in, experimental, with a caveat, or only through a third-party package (named) · ❌ not built in · 🔜 planned by the maintainers, no code yet · — does not apply. The Yjs, Automerge and Loro columns were decided from their repositories as cloned on 2026-10-23 — Yjs main at 0e0dc34 (14.0.0-rc.29; npm latest is still 13.6.33), Automerge main at 5bae775 (3.5.0) with automerge-repo at 3ab7c33 (2.6.0-alpha.5), Loro main at 75e4bf2 (1.17.1) — and their official docs; where a claim was cheap to test, the published package was run. The syncom column is this repository.
Package and platform
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| Package and license | ||||
| Version compared | @alexify/syncom 0.0.1 at 7be9388; never published to npm1 | @y/y 14.0.0-rc.29 at 0e0dc34; npm latest is yjs 13.6.332 | @automerge/automerge 3.5.0 at 5bae775 (= npm latest); Rust crate 0.12.03 | loro-crdt 1.17.1 at commit 75e4bf2 |
| Release status | pre-release, unpublished; repository started in 2026-09 | v14 release candidate; v13 is the stable line4 | stable: 3.x since July 2025, 2.0 in Jan 2023; LICENSE from 20195 | stable 1.x since 1.0.0 (2024-10-21); open source since 2023-11 |
| License | MIT, every subpath included; wrpc, the transport, MIT too6 | MIT; y-protocols, y-indexeddb, y-leveldb and y-crdt also MIT | MIT: core, WASM, JS, C, automerge-repo and its adapters7 | MIT (core, @loro-dev/fractional-index, Loro Protocol packages)8 |
| Maintained by | Alex Dolid (Alexis Technologies) | Kevin Jahns (dmonad), sponsor-funded independent maintainer9 | Ink & Switch; Alex Good and Orion Henry work on it full time | Loro team (Zixuan Chen, Liang Zhao), GitHub org loro-dev |
| Implementation and runtimes | ||||
| Implementation | pure JavaScript: CommonJS, no build step | pure JavaScript (ESM source, JSDoc types), no build step | Rust core compiled to WASM (wasm-bindgen), TypeScript wrapper with proxies | Rust core compiled to WASM with wasm-bindgen10 |
| Runtime dependencies of the core package | 011 | 1: lib0 (^1.0.0-rc.35), by the same maintainer12 | 0: no dependencies; the WASM binary ships inside the package | 0 dependencies |
| WebAssembly | — none: pure JavaScript | — not used: pure JS; ywasm is a separate Rust build | required: 3.6 MB .wasm (1.1 MB gzip), loaded per export condition13 | required; entries load it sync (Node, browser), async (web) or inline (base64)14 |
| Other languages | ❌ javaScript only | ✅ Rust yrs, JS ywasm, C yffi; Python, Ruby, .NET, Swift, Kotlin on yrs15 | ✅ Rust crate and C API in repo; docs list Python, Swift, R16 | ✅ Rust loro, Swift loro-swift, Python loro-py; C# and Go by the community17 |
| JavaScript runtimes | node.js ≥ 22 (CI on 22 and 24); browsers through OPFS or IndexedDB18 | ESM only; Node ≥ 22 and browsers; CI on Node 2619 | Node (CI tests 18), browsers, Workers (workerd), Deno; React Native via docs recipe20 | Node, browsers, Deno, Bun (all run in CI); no engines field21 |
| TypeScript types | ⚠️ hand-written .d.ts, tsd-tested; records typed unknown22 | ✅ generated .d.ts from JSDoc; generic Y.Node<DConf> types attrs and children23 | ✅ shipped .d.ts from TS source; generic Doc<T>, change<T>, from<T>24 | ✅ shipped .d.ts: wasm-bindgen output plus hand-written generics (LoroDoc<T>, LoroMap<T>) |
| API style | async methods by record id; live Record objects stage edits and save()25 | mutable shared objects; every call synchronous | immutable doc; change(doc, d => …) mutates a proxy, returns a new doc; synchronous26 | mutable container handles; synchronous calls that throw on error |
Document model
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| What merges | ||||
| The unit that merges | a record: a JSON object with an id, many per storage27 | Y.Doc: named root nodes plus nested subdocuments | a document: one root map tree plus its full hash-linked change history | a LoroDoc: its op log (history) plus the state of named root containers |
| Many units in one place | ✅ a storage holds records by id, in collections, with queries | ⚠️ subdocuments: a Y.Doc as a value, addressed by guid28 | ✅ automerge-repo Repo: automerge: URLs, create/find, sync per document29 | ❌ one LoroDoc per document; no registry or subdocuments30 |
| Data types | register, counter, OR-set, list (movable), text (with marks), tree | one Y.Node: attrs (map), children (list or text), formats, name31 | map, list, text (with marks and block markers); Counter as a scalar32 | Map, List, MovableList, Text (rich text), Tree, Counter |
| Nested types | ❌ no nested containers: every kind is a record's top-level field33 | ✅ any node holds nodes in its attrs and its children34 | ✅ any depth: maps and lists hold maps, lists and texts; root is a map35 | ✅ Map, List and MovableList hold any container; each tree node holds a map36 |
| How a structure is created | by field name: the first op decides the kind (inc, add, splice, treeMove)37 | ydoc.get(key): roots by name, no create op; same node everywhere38 | one root map; children created by ops; same-key concurrent creation conflicts39 | roots by name and type (getMap("x")) merge across replicas; children are minted by ops40 |
| Plain values a field holds | JSON only; other values pass through JSON.stringify (Date → string)41 | JSON, Uint8Array, undefined, bigint, Date, nodes, docs42 | str, int, uint, f64, bool, null, Date, Uint8Array, Counter, ImmutableString43 | JSON values, Uint8Array, container refs; undefined becomes null44 |
| A plain nested object | one register: replaced whole, the newer writer wins45 | atomic: one value, replaced whole46 | becomes a nested map: its keys merge one by one; reassigning replaces it47 | one atomic value, replaced whole by the map's LWW |
| Two types created at one key concurrently | fixed precedence, not stamps: register > counter > set > list > text > tree48 | one node wins by clientID; the loser and its content are deleted49 | one wins by op id; the loser stays, readable through getConflicts50 | Map LWW shows one; the loser stays readable by its container id51 |
| Schema of a document | ✅ entity schemas, injected validator (Standard Schema, a function, compile)52 | ⚠️ type-level Y.Node<DConf>; $node checks only the name53 | ❌ none; load<T> trusts T, migrations are the app's job54 | ⚠️ none in core; loro-mirror declares a schema for its state view55 |
| Identity, clocks and versions | ||||
| Replica identity | nodeId: the Ed25519 public key, 43 base64url characters, minted at first open56 | doc.clientID: random 53-bit integer, new per Doc; app may set it57 | actor ID: bytes (hex in JS), 16 random by default, app may set it58 | random u64 PeerID per LoroDoc; settable with setPeerId() |
| Two replicas with one id | ⚠️ one open per directory (lock); SELF and REWOUND caught at hello59 | ⚠️ regenerates on seeing its own id; replicas may already have diverged60 | ⚠️ detected at merge: duplicate seq N found for actor error; no recovery61 | ⚠️ importing a clashing op id throws UsedOpID; nothing regenerates the id62 |
| A user above replica ids | ✅ actor on every op (open({ actor }) or per write), signed and replicated63 | ⚠️ no: the app maps clientIDs to users64 | ✅ author (hex) mapped to many actors: getAuthors, getActorsForAuthor (3.5.0)65 | ⚠️ no: map peer ids to users, e.g. in subscribeFirstCommitFromPeer |
| Operation id | (origin, seq) per origin and scope stream, plus its stamp ts66 | ID(client, clock) per element; a run shares one item67 | counter@actor: op counter, then actor, e.g. 2@aaaa01 | { peer, counter } per op; each change also carries a Lamport |
| Clock behind the merge order | hybrid wall clock max(Date.now(), last + 1) that observes peers; vectors for delivery68 | per-client counters (state vector); no Lamport merge, no wall time69 | Lamport op counters (max seen + 1); causality via the change-hash DAG70 | Lamport; ties broken by larger peer id |
| Wall-clock time on a change | ✅ always: each op's ts is milliseconds and the merge order71 | ❌ no timestamp on a change72 | ✅ per-change time (seconds), on by default; advisory, not in merge order73 | ⚠️ opt-in setRecordTimestamp(true), in seconds; never used for merge order74 |
| A version | a version vector { 'origin@scope': seq } as JSON; event ids, stamps, tags75 | state vector (client → next clock), varint binary; snapshot adds deletes76 | heads: an array of SHA-256 change hashes as hex strings77 | VersionVector (peer→counter) and Frontiers (OpId[]), both encodable |
| Compare two versions | ❌ no API: a vector is a plain object, compared key by key | ⚠️ snapshotContainsUpdate, equalSnapshots; no ahead/behind/concurrent test78 | ⚠️ hasHeads(doc, heads) tests inclusion; no ahead/behind/concurrent compare79 | ✅ vv.compare(other) and doc.cmpFrontiers(a, b) return -1/0/1/undefined |
Data types
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| Registers and maps | ||||
| Map / object type | ✅ a record: insert, get, update, set, delete, a live Record | ✅ Y.Node attrs: setAttr, getAttr, deleteAttr, hasAttr, getAttrs80 | ✅ JS object proxy: d.k = v, delete d.k; Rust put, get, get_all | ✅ LoroMap: set, get, delete, setContainer, ensureMergeable*, keys |
| Concurrent writes to one key | per field by (ts, origin): the wall-clock stamp, then the node id | the unseen concurrent write with higher clientID wins, not wall-clock81 | greater op id wins (counter, then actor); not wall-clock | higher Lamport wins, then larger peer id; no wall clock |
| Losing values readable | ❌ no multi-value register; a lost value is readable only in history | ❌ losing values are deleted, not readable82 | ✅ getConflicts(obj, key) returns every concurrent value; Rust get_all83 | ❌ no; the losing values are reachable only through history |
| Delete a key | ⚠️ no per-field delete: set() rewrites the record without the field84 | ✅ deleteAttr(key); a concurrent set survives the delete85 | ✅ delete d.k; a concurrent set beats the delete | ✅ map.delete(key); against a concurrent set, the same Lamport+peer LWW |
| Clear a map | ✅ set(id, {}) or delete(id): a reset; newer concurrent ops survive86 | ✅ clearAttrs(): deletes visible keys in one change; concurrent sets survive87 | ❌ no clear op; delete keys one by one or reassign the parent88 | ⚠️ map.clear() writes one delete per present key89 |
| Key order when read | as each node's object took the keys; replicas can differ90 | local Map insertion order; can differ between replicas91 | sorted by key in the core; a live JS doc appends newly added keys92 | keys()/entries() sorted by key; toJSON() order unspecified |
| Counters and sets | ||||
| Counter type | ✅ inc(id, key, value): one running sum per origin, added up | ❌ no counter type93 | ✅ Counter class: increment(n), decrement(n), .value | ✅ LoroCounter: increment(n), decrement(n), value |
| Counter values | finite floats; a negative inc decrements; each origin's sum within ±2^53−194 | — no counter type | 64-bit integer; decrement allowed; fractional deltas truncated95 | f64 floats; decrement; no bounds or overflow checks |
| A counter reset while others add | a reset drops increments stamped below it; a register outranks the counter96 | — no counter type | an overwrite or delete wins; concurrent increments are dropped97 | no reset op; a concurrent increment lands in the deleted counter, hidden98 |
| Set type (concurrent adds kept) | ✅ oR-set field: add() / remove(); a concurrent add survives a remove99 | ❌ no set type; map keys behave add-wins100 | ❌ no set type; the docs give no set recipe | ❌ no set type; map keys give LWW, not add-wins |
| Lists | ||||
| List type | ✅ list field: splice, push, setAt, move, cursor / index | ✅ Y.Node children: insert, push, unshift, delete, get, slice101 | ✅ JS array proxy: push, unshift, insertAt, deleteAt, splice, index assign | ✅ LoroList and LoroMovableList: insert, delete, push, pop, get |
| List merge algorithm | Fugue (Weidner & Kleppmann 2023), not FugueMax; movable per Kleppmann 2020102 | YATA (Nicolaescu, Jahns et al., 2016) with an added originRight103 | RGA, as the docs name it; no paper cited | Fugue, merged by Eg-walker-style replay; MovableList moves per Kleppmann 2020 |
| Concurrent inserts at one spot | ✅ never: each writer's run stays whole, forward and backward104 | ⚠️ two runs stay whole, forward or backward; a three-replica case interleaves105 | ⚠️ forward runs stay whole; runs typed backwards interleave element by element106 | ✅ no interleaving, forward or backward |
| Insert and delete ranges | ✅ splice(id, key, index, count, items): one op for both | ✅ insert(i, [many]) and delete(i, n) in one call | ✅ splice(i, n, ...items), insertAt(i, ...items), deleteAt(i, n) | ⚠️ delete(pos, len) takes a range; insert takes one value per call107 |
| Replace an element in place | ✅ setAt(id, key, index, value): a value register per element, newer stamp wins108 | ❌ no in-place assign; delete and insert109 | ✅ d.l[i] = v writes in place; concurrent assigns conflict, op id wins110 | ✅ LoroMovableList.set(i, v); concurrent sets keep one, by Lamport+peer111 |
| Move an element | ✅ move(id, key, from, to); two concurrent moves leave the element once112 | ❌ no move op113 | ❌ no move op; assigning an existing object elsewhere throws | ✅ LoroMovableList.move(from, to); the element stays once, last mover wins |
| Concurrent move and delete | the move wins: the element comes back at the new place114 | — no move op | — no move op | the move wins: the element comes back at its new place115 |
| Containers inside a list | ❌ elements are JSON values, each atomic; no containers inside | ✅ children may be nodes: lists of maps of texts | ✅ lists hold maps, lists and texts at any depth | ✅ insertContainer/pushContainer; setContainer on MovableList |
| Runs stored compactly | ✅ content run-length encoded; a writer's next keystroke extends its own run116 | ✅ consecutive inserts by one client merge into one item117 | ✅ columnar RLE/delta op columns, kept compressed in memory since 3.0 | ✅ consecutive ops of one peer merge into runs and changes; columnar blocks |
| Text | ||||
| Text type | ✅ text field: splice, push, updateText, mark, spans, cursor | ✅ text is a node's string children: insert, delete, format, toString118 | ✅ a JS string field is collaborative text; edit with splice, updateText119 | ✅ LoroText: insert, delete, splice, mark, toDelta, update |
| Text merge algorithm | Fugue, the same engine as lists (src/sequence.js) | the same YATA list engine as lists | the same RGA as lists; marks follow Peritext | same Fugue tracker as lists, Eg-walker-style replay; state in a B-tree rope |
| Index units | UTF-16 code units, as String#slice counts them; no other units | UTF-16 code units; no other unit in the JS API120 | UTF-16 code units in JS; Rust also offers code points, UTF-8, graphemes121 | UTF-16 by default; insertUtf8/deleteUtf8 and convertPos for code points and UTF-8 |
| A split surrogate pair | ❌ not checked: a split pair leaves lone surrogates122 | ⚠️ a split pair becomes two U+FFFD; no error123 | ✅ an index inside a pair rounds to the whole character; never splits it124 | ✅ throws UTF16InUnicodeCodePoint; the text is unchanged |
| Splice at an index | ✅ splice(id, key, index, count, text); past the end is OUT_OF_RANGE | ✅ insert(index, string, formats?) and delete(index, length) | ✅ splice(doc, path, index | cursor, del, text); negative del deletes backwards | ✅ insert(pos, s), delete(pos, len), splice(pos, len, s) |
| Set a whole new string (diff) | ✅ updateText(id, key, text): Myers over UTF-16, one splice per hunk125 | ❌ no set-whole-string API126 | ✅ updateText(doc, path, s): Myers diff over grapheme clusters127 | ✅ text.update(s) (Myers diff) and updateByLine(s); timeoutMs option128 |
| Delta format | ❌ no deltas; spans() reads runs of { text, marks } | ✅ lib0 deltas in and out: toDelta(), applyDelta(), event.delta129 | ❌ no Quill deltas; spans()/updateSpans() and patches instead | ✅ toDelta(), applyDelta(), sliceDelta(); text events are deltas |
| Embeds in text | ❌ a text holds characters only | ✅ JSON objects or nodes inside text | ✅ block markers (inline maps); isEmbed: true schema convention for images | ❌ strings only; a delta insert must be text |
| Long documents | ⚠️ the whole record is read and written per op; slow on long texts130 | one item per typed run; B4 (105k chars): 160 KB doc, 3.2 MB heap131 | every keystroke kept; a 260k-edit trace saves to 129 KB, loads in 0.13 s132 | B-tree text, O(log n) edits; 260k-op trace: 231 KB, 6 ms parse133 |
| Rich text | ||||
| Formatting marks | ✅ mark(), unmark(), unmarkById(), spans(): ranges between two anchors | ✅ format(index, length, { key: value }); insert takes formats134 | ✅ mark(doc, path, {start, end, expand}, name, value), unmark, marks, marksAt135 | ✅ text.mark({ start, end }, key, value), unmark(range, key)136 |
| Typing at a mark's edge | ✅ per mark call: expand: 'after' | 'before' | 'both' | 'none'137 | ⚠️ none: an insert carries only the formats it names138 | ✅ per mark call: before, after, both, none; default after139 | ✅ per key via configTextStyle: before, after, both or none |
| Overlapping marks of one name | ✅ { many: true } marks stack, each with its own id and value140 | ⚠️ one value per key at any position141 | ⚠️ one value per name per character; overlaps split, the greater op wins142 | ⚠️ one value per key per character; comment:<id> keys overlap143 |
| Remove a mark | ✅ unmark(range, name) is a null mark; the newer stamp wins against a mark144 | ✅ format(i, n, { key: null }); marker order decides against a concurrent mark | ✅ unmark(doc, path, range, name); against a concurrent mark, op id decides145 | ✅ unmark(range, key); against a concurrent mark, Lamport+peer LWW |
| Concurrent marks of one name | the newest stamp (ts, origin) covering the character | the marker later in list order wins; ties by clientID, not time | per character, the greater op id (counter, then actor) wins | per character, higher Lamport then larger peer wins |
| Blocks (paragraphs, headings) | ❌ no block type; the docs model blocks as records in a list | ✅ named child nodes (new Y.Node('p')), or Quill-style newline formats | ✅ block markers: splitBlock, joinBlock, updateBlock, block, spans146 | ❌ no block markers; model blocks as containers |
| XML / DOM-like type | ❌ none | ✅ Y.Node(name): name, attrs, children; toString() renders XML147 | ❌ no element tree type; block markers with parents paths instead | ❌ no XML type; use Map, List or Tree |
| Editor bindings | ❌ none; updateText() plus change events are the binding point | ✅ y-prosemirror (Tiptap, BlockNote), y-quill, y-codemirror, y-monaco; Lexical native148 | ✅ official automerge-prosemirror and automerge-codemirror (outside the clones)149 | ✅ loro-prosemirror (works with Tiptap) and loro-codemirror150 |
| Positions and trees | ||||
| Stable cursor | ✅ cursor(id, key, index) → a string; index(id, key, cursor) reads it back | ✅ createRelativePositionFromTypeIndex and createAbsolutePositionFromRelativePosition151 | ✅ getCursor(doc, path, index), getCursorPosition; splice accepts a cursor152 | ✅ text.getCursor(pos, side) → Cursor; doc.getCursorPos(cursor) |
| Cursor side (affinity) | ✅ side: 'before' (the default) or 'after' | ✅ assoc: ≥ 0 sticks to the next element, < 0 to the previous | ⚠️ anchored to the character at the index; move only sets the deletion fallback153 | ✅ Side -1 (before), 0, 1 (after) at getCursor |
| Cursor as a value to keep or send | ✅ a string (<elem, >elem, ^, $) to keep or send | ✅ bytes (encodeRelativePosition) or JSON (relativePositionToJSON) | ✅ a string such as 8@00, s, e; Rust Cursor::to_bytes() | ✅ cursor.encode() bytes, Cursor.decode(bytes) |
| Cursor into deleted content | the next visible element; null once a reset (or compact) dropped it154 | the gap where the element was; null once collected155 | resolves to the next (after, default) or previous visible element | resolves to where it was, plus an update cursor on live content156 |
| Fractional indexes | ✅ @alexify/syncom/order: between, spread, compare, isKey | ❌ no fractional-index helper | ❌ no fractional-index helper | ✅ @loro-dev/fractional-index (TS) and Rust loro_fractional_index157 |
| Movable tree type | ✅ tree field: treeMove(id, key, node, parent, { after | before }), treeRemove | ⚠️ no movable tree; third-party yjs-orderedtree builds one on maps158 | ❌ no tree type | ✅ LoroTree: createNode, move, moveBefore/moveAfter, delete |
| Concurrent moves that would form a cycle | Kleppmann et al. 2021: moves fold in stamp order, the cyclic one skipped159 | — no movable tree | — no tree type | Kleppmann 2021: moves applied in Lamport order, a cycle-making move is ignored160 |
| Order among siblings | ordered: a fractional index per move, ties broken by stamp | — no movable tree; nested nodes' children are an ordered list | — no tree type | ordered siblings by fractional index, on by default; index()161 |
| Delete a subtree | ✅ treeRemove moves it under $trash with its subtree; moving it out restores it162 | — no movable tree; deleting a node deletes its subtree163 | — no tree type | ✅ delete(id) hides the subtree; a node moved into it is hidden too164 |
| Data on a node | ⚠️ none on the node: a node is a name, usually a record id | — no movable tree; a nested node carries its own attrs | — no tree type | ✅ a LoroMap per node (node.data) |
Changes and events
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| Writing | ||||
| Group edits into one change | ⚠️ one call is one op; applyPatches runs a record's ops in one turn165 | ✅ doc.transact(fn, origin): one event round, one update for peers166 | ✅ change(doc, fn) is one change: one hash, one callback; a throw rolls back167 | ✅ an implicit transaction closed by commit(): one event batch, one change168 |
| When an edit becomes a change | every call: durable once its op is appended to the log169 | each call outside transact is its own transaction170 | JS commits when the change callback returns; Rust AutoCommit before reads and saves171 | on commit(), and implicitly before import, export and checkout; no timer |
| Message and metadata on a change | ⚠️ actor (signed, replicated), ts, schema v; no message or custom fields | ❌ nothing travels with a change; transaction.meta is local | ✅ message, time, author (hex, 3.5.0); no custom fields172 | ✅ commit({ message, timestamp }); the author is the peer id173 |
| Tag a change with an origin | ⚠️ a local source on commit events; actor replicates174 | ✅ transact(fn, origin), applyUpdate(doc, u, origin); local only, not replicated175 | ⚠️ the patch callback's source names the API call; no app-defined tag176 | ✅ commit({ origin }): local only, in events and undo filters177 |
| One atomic change across documents | ❌ no: an op touches one record | ❌ a transaction covers one Doc178 | ❌ no; a change belongs to one document | ❌ no |
| Hook before a local change commits | ✅ hooks.beforeWrite(ctx): return new data or throw to refuse179 | ❌ no veto; beforeObserverCalls sees the applied change180 | ❌ no hook; a throw inside the change callback rolls it back | ⚠️ subscribePreCommit edits message and timestamp; no veto |
| Refuse a peer's change | ✅ beforeApply returning false, or a failing validator, skips it here181 | ❌ no way to reject part of a remote update182 | ⚠️ initSyncState({ readOnly: true }) ignores all of one peer's changes183 | ❌ no; imports get structural checks only |
| Observing | ||||
| Change events | ⚠️ storage change per op, Record update, Cursor.watch(); no per-field observers | ✅ observe, observeDeep, a delta event per node; doc update, afterTransaction184 | ✅ patchCallback per call or per doc; automerge-repo DocHandle change events185 | ✅ doc.subscribe, deep container.subscribe, subscribeLocalUpdates (bytes) |
| What an event carries | the op itself; commit adds before / after copies and the kinds186 | lib0 delta (event.delta, deltaDeep), changed keys; update gives bytes187 | path patches (put, splice, mark…) plus before/after docs and source188 | per container: target, path, delta/map/tree/counter diff; from/to frontiers189 |
| Local or remote in an event | ✅ via is null for a local op; commit fires for local ops only | ✅ transaction.local and transaction.origin | ✅ info.source: change vs merge, applyChanges, receiveSyncMessage… | ✅ by: "local", "import" or "checkout"; plus origin |
| Changes as JSON patches | ✅ diff(id, from, to) → JSON patches in the write API's terms190 | ✅ deep lib0 deltas with nested modify/modifyAttr per change191 | ✅ Patch[] with paths, from callbacks and from diff() | ✅ events and doc.diff() give typed diffs with paths (not RFC 6902) |
| Apply a patch or diff back | ✅ applyPatches(id, patches): new ops, rehearsed, all or none on a refusal | ✅ node.applyDelta(delta, origin) turns a delta into ops192 | ⚠️ applyPatches(target, patches): to a plain object, or as ops inside change193 | ✅ doc.applyDiff(diff), all or nothing; text.applyDelta() |
| Subscribe by path | ⚠️ per record (record(id).on('update')) or per query (watch); no field paths | ❌ observe a node or its subtree; no path filter194 | ⚠️ automerge-repo handle.sub(...path): scoped handle with re-rooted patches (experimental)195 | ✅ doc.subscribeJsonpath(path, cb); may fire false positives |
| When observers run | once the op is durable: listeners called synchronously, before the write resolves196 | synchronously when the outermost transaction ends, before update197 | synchronously, after the commit, before the call returns | synchronously at the end of commit, import or checkout (since 1.8) |
History and versions
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| History | ||||
| History kept | ⚠️ the op log, bounded by retention (10 000 ops by default)198 | ❌ no change log; default GC drops deleted content199 | ✅ every change is kept; there is no history GC | ✅ every change kept; nothing is collected unless you export a shallow snapshot |
| List the changes | ✅ history(id, { limit, before }): versions with type, origin, actor, fields, data200 | ❌ no API to list changes201 | ✅ getAllChanges, getChangesMetaSince, inspectChange(hash), decodeChange202 | ✅ getAllChanges(), getChangeAt(), getOpsInChange(), travelChangeAncestors() |
| Read a past version | ✅ get(id, { at }) by event id, stamp, vector or tag; at(tag) views | ✅ Y.snapshot(doc) then createDocFromSnapshot, or getAttrs(snapshot)203 | ✅ view(doc, heads): a cheap read-only doc at a version; Rust *_at(heads) | ✅ doc.checkout(frontiers) (detached view), forkAt(frontiers) |
| What reading the past needs | ⚠️ the retained log: a fold of the record's ops, exact from a reset204 | ⚠️ gc: false and a snapshot captured then; restore rebuilds a doc205 | ✅ nothing: history is always whole; reads filter ops by a clock, no replay206 | ✅ history back to the target (not before a shallow root); replays only the ops between |
| Diff two versions | ✅ diff(id, from, to) between any two versions, as patches207 | ✅ diffDocsToDelta(v1, v2); DiffRenderer attributes the changes208 | ✅ diff(doc, before, after) → Patch[] between any two versions209 | ✅ doc.diff(from, to) between any two frontiers |
| Revert to a past version | ✅ restore(id, at): new ops that lead back, whole or not at all210 | ⚠️ experimental undoContentIds, or DiffRenderer.rejectAllChanges211 | ❌ no revert API; applyPatches(diff(now, past)) inside change is manual212 | ✅ doc.revertTo(frontiers) as new ops, all or nothing |
| Named versions | ✅ tag(name), untag, tags(), at(name): the whole storage, copy-on-write213 | ❌ no named versions; snapshots are bytes the app stores | ❌ no named versions; the app keeps heads itself | ❌ no; keep frontiers yourself (encodeFrontiers) |
| Who wrote what | ⚠️ per op: history() names origin, actor and fields; no per-character API | ⚠️ v14 attribution renderers; who did what is the app's map of clientIDs214 | ⚠️ no blame API; per-change actor/author metadata and diff per change215 | ✅ text.getEditorOf(pos), map.getLastEditor(key), list getCreatorAt/getLastEditorAt216 |
| Drop old history | ✅ log retention drops old segments; compact(id) rewrites a record's metadata217 | ⚠️ GC drops deleted content; tombstone ids and positions stay218 | ❌ no shallow snapshot or history GC; compression keeps size down | ✅ shallow snapshot drops history before chosen frontiers, like a Git shallow clone219 |
| Erase a payload from history | ✅ purge(id) scrubs the payload from the log here and on every peer it reaches220 | ⚠️ default GC drops deleted content here and on peers that GC221 | ❌ no; deleted content stays in the history on every replica222 | ⚠️ a shallow snapshot leaves deleted content out; other replicas keep theirs223 |
| Branches and undo | ||||
| Fork a document | ✅ branch(parent, { path }) (@alexify/syncom/branch): a new storage, new identity224 | ✅ Y.cloneDoc(doc); the clone is a new Doc with a new clientID225 | ✅ clone(doc) copies with a new random actor; Rust fork() | ✅ doc.fork() with a new random peer id |
| Fork at a past version | ⚠️ at a tag only: branch(parent, { from: { tag } }) | ✅ createDocFromSnapshot(doc, snapshot) gives an editable doc226 | ✅ clone(view(doc, heads)); Rust fork_at(heads) | ✅ doc.forkAt(frontiers) |
| Merge a fork back | ✅ merge(source, { into }), either way, as ops through applyRemote227 | ✅ exchange updates; DiffRenderer accepts or rejects suggestions selectively | ✅ merge(local, remote), or applyChanges(getChanges(old, new)) | ✅ sync like any peer: import(fork.export({ mode: "update", from })) |
| Edit while viewing the past | ⚠️ views of the past are read-only; edit through restore() or a branch at a tag | ⚠️ no checkout; a past version is a new Doc, edited as a fork | ✅ view is read-only; changeAt(doc, heads, fn) adds a change concurrent with later history228 | ⚠️ read-only unless setDetachedEditing(true); edits then branch off229 |
| Suggestions (tracked changes) | ❌ none; a branch (/branch) merges back whole | ✅ DiffRenderer keeps a suggestion doc; acceptChanges/rejectChanges by range230 | ⚠️ maskAuthor/revealAuthor hide an author's changes in one view231 | ❌ none; a fork merges back whole |
| Undo manager | ✅ createUndo(storage) from @alexify/syncom/undo: undo(), redo() | ✅ Y.UndoManager(scope, options) with undo(), redo() | ❌ no undo manager in the core or automerge-repo | ✅ UndoManager: undo, redo, canUndo, pause/resume |
| Undo only local changes | ✅ this node's own commits only; an undone add revokes its own adds alone | ✅ tracks origins in trackedOrigins (default null); peers' changes stay232 | — no undo manager | ✅ tracks this doc's peer only; never undoes remote edits233 |
| Undo steps grouped | ✅ mergeInterval (500 ms), mark() closes a step, limit 100 | ✅ captureTimeout (500 ms), stopCapturing(), captureTransaction filter | — no undo manager | ✅ mergeInterval (default 1000 ms) and groupStart/groupEnd |
| Undo over part of a document | ⚠️ scope (one sync scope) and exclude(op); no list of tracked fields | ✅ scope of nodes or a whole doc; trackedOrigins, deleteFilter | — no undo manager | ⚠️ excludeOriginPrefixes only; no per-container scope |
| Selection restored on undo | ❌ no selection metadata on a step | ✅ stackItem.meta with stack-item-added / stack-item-popped events | — no undo manager | ✅ onPush returns cursors, onPop gets them back transformed |
| Undo after a peer edited the same place | skipped (refuse to overwrite); force overrides; counters and sets commute234 | keeps peers' later edits; skips a key a peer overwrote235 | — no undo manager | steps are transformed over remote edits (OT-style); remote inserts stay |
Encoding and sync
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| Encoding | ||||
| Encoding of changes | JSON: keyed { seq, op } in the log, 17-slot arrays on the wire236 | binary updates: V1 (default) and V2 (columnar, RLE per field) | binary columnar chunks: document, change, DEFLATE-compressed change, bundle237 | binary FastSnapshot and FastUpdates (modes 3, 4); JSON updates |
| Format specification | ⚠️ op shape and slots in the guides and types; no standalone spec | ❌ no written spec; the code defines the format238 | ✅ published binary format spec on automerge.org (not in the clones) | ✅ docs/encoding.md, a normative spec, plus JSON, LZ4 and xxHash docs |
| Snapshot of the whole state | ✅ snapshot() pages records with their merge meta; mergeSnapshot() joins them239 | ✅ encodeStateAsUpdate(doc): all items and tombstones240 | ✅ save(doc): compressed doc with full history; no history-less save241 | ✅ export({ mode: "snapshot" }) with history; "shallow-snapshot" with little |
| Changes since a version | ✅ a push sends ops past the host's vector; changes resumes from an event id242 | ✅ encodeStateAsUpdate(doc, stateVector) sends what that peer lacks243 | ✅ saveSince(doc, heads), getChangesSince(doc, heads), saveIncremental() | ✅ export({ mode: "update", from: vv }) |
| Merge updates without loading a document | ✅ mergeEntry(a, b) joins two record states with no storage244 | ✅ mergeUpdates, diffUpdate, encodeStateVectorFromUpdate on bytes245 | ⚠️ saved chunks concatenate: load(concat(a, b)) works; no compaction without loading246 | ❌ no; importBatch the blobs into a doc, then export247 |
| Readable export of changes | ✅ everything is JSON: snapshot pages, log entries, ops248 | ⚠️ decodeUpdate/logUpdate show structs for inspection; no JSON import | ⚠️ decodeChange/encodeChange (@hidden) map a change to and from JSON ops249 | ✅ exportJsonUpdates() / importJsonUpdates() |
| Fast or lazy load | ⚠️ records load by id into a bounded cache; each one parsed whole250 | ⚠️ compact V2 columns; loading decodes everything, no lazy load251 | ✅ columnar doc chunk; 3.0 keeps it compressed in memory; no change replay252 | ✅ lazy ~4 KB compressed blocks, decoded on demand; columnar encoding |
| Format compatibility | ⚠️ no promise yet: unpublished, and PROTOCOL 1 is the first release's wire253 | ⚠️ no written promise; tests decode v13.2 documents254 | ✅ 3.0 kept the 2.x file format; sync v2 still talks to v1 peers255 | ✅ stable since 1.0; older readers refuse newer encodings256 |
| Sync | ||||
| Sync protocol | ✅ built in: hello, push, changes, snapshot over an injected link | ✅ y-protocols/sync: step 1 state vector, step 2 diff, updates257 | ✅ core generateSyncMessage / receiveSyncMessage with a per-peer SyncState258 | ✅ none in core; the official Loro Protocol (loro-websocket, loro-adaptors)259 |
| What a reconnect exchanges | vectors and log epochs at hello, then ops past the vector; the feed resumes260 | state vectors; the reply resends missing items and the full delete set | heads + a Bloom filter of changes since the shared heads + needs261 | version vectors; each side exports the ops past the other's vector |
| Updates out of causal order | ✅ a gap in one origin's stream is dropped and fetched again262 | ✅ held as pending and applied when the missing parts arrive263 | ✅ queued until dependencies arrive; getMissingDeps lists them264 | ✅ held as pending until the deps arrive; exportPendingUpdates() saves them |
| The same update twice | ✅ idempotent: the vector drops it, counted in duplicates; no event | ✅ idempotent: no event, no observers; the cost is a decode | ✅ idempotent: deduplicated by hash, no patches emitted | ✅ idempotent: no event, empty success |
| Transport | ✅ an injected SyncLink; @alexify/syncom/wrpc for WebSocket and WebRTC265 | ✅ transport-agnostic bytes; official y-websocket, y-webrtc providers266 | ✅ bytes over any reliable channel; automerge-repo WebSocket, MessageChannel, BroadcastChannel267 | ✅ bytes over any transport; WebSocket client in loro-websocket |
| Official server | ✅ no separate server: any storage hosts with sync.unit() on wrpc268 | ✅ y-websocket ships a simple WebSocket backend269 | ✅ automerge-repo-sync-server (reference, outside the clones): a plain Repo, validates nothing270 | ⚠️ loro-websocket's SimpleServer (for testing) and a Rust server271 |
| Persistence | ✅ built in: node:fs, OPFS, IndexedDB (/idb), memory, or an injected fsi | ✅ official y-indexeddb and y-leveldb; others third-party272 | ✅ automerge-repo IndexedDB and Node FS adapters over a key-range StorageAdapter273 | ❌ the app's job; docs give a snapshot plus updates recipe |
| Presence and awareness | ❌ none | ✅ y-protocols/awareness: per-client state, 30 s timeout | ✅ automerge-repo handle.broadcast() / ephemeral-message; Presence class274 | ✅ EphemeralStore: timestamped LWW keys that expire |
| Partial replication | ✅ scopes: a scope() per record, peers granted scopes, the host enforcing275 | ⚠️ per document via subdocuments; intersectUpdateWithContentIds filters by ids276 | ⚠️ per document only: automerge-repo shareConfig announce / access277 | ❌ no; a peer receives the whole document |
Integrity and safety
| Feature | syncom sync engine | Yjs CRDT library | Automerge CRDT library | Loro CRDT library |
|---|---|---|---|---|
| Integrity | ||||
| Hash-linked history | ⚠️ a per-node hash-linked chain of applied writes, checked on cold reads278 | ❌ ids are client and clock; no content hashes | ✅ SHA-256 change hashes; deps link them; heads checked on load | ❌ no; ops are named by peer and counter279 |
| Signed changes | ✅ Ed25519 per op; the node id is the key; verified on receipt280 | ❌ no signatures | ❌ no signatures on changes in the core281 | ❌ no |
| Checksums on encoded data | ⚠️ a signed SHA-256 digest per op; record files checked against the chain282 | ❌ no checksum in either format | ✅ each chunk carries a 4-byte SHA-256-prefix checksum, checked on load | ✅ xxHash32 over every blob, plus one per SSTable block |
| A malformed update | ✅ dropped and counted, never thrown: not logged, not relayed (a gap)283 | ⚠️ throws a plain Error; parts decoded before the failure stay applied284 | ⚠️ undecodable bytes throw, nothing applied; nonsense ops panic and broke the doc285 | ✅ throws; the import rolls back and the doc is unchanged286 |
| Limits against a hostile peer | ✅ pending work, run and field slots, tree depth and index, counter range, skew, push limits287 | ❌ no limits on size, depth or pending data288 | ⚠️ 1 GB cap per decoded item, leb128 overflow checks; no queue or size caps289 | ⚠️ decode caps (positions < 2^30, value depth 128); no size or pending caps290 |
| Validation of a peer's change | ✅ the entity validator and beforeApply run on every peer's op | ❌ none; peers are assumed trusted | ❌ none in the core; Keyhive's server policy (alpha) checks edit access only | ❌ no schema or rule checks; structural checks only |
| End-to-end encryption | ❌ no; an encrypted record stays local and does not sync | ⚠️ none in core; y-webrtc encrypts signaling with a shared secret291 | ⚠️ keyhive via @automerge/automerge-repo-keyhive, in alpha292 | ⚠️ experimental, in the Loro Protocol (%ELO rooms, EloLoroAdaptor) |
| Convergence tested by fuzzing | ✅ seeded mesh fuzz: 4 nodes, 80 ops of every kind; a hazard walk293 | ✅ 5 peers, random disconnects and delivery order, up to 100,000 ops | ✅ proptests, random fork/merge batch tests, a 3-repo fuzz, libFuzzer on load294 | ✅ cargo-fuzz, 14 targets, 5 peers compared; loro.js-vs-Rust differential fuzz |
| Formal model or proofs | ❌ none; cites the papers, and INVARIANTS.md maps each guarantee to tests | ⚠️ third-party lean-yjs (Lean) proves parts of YATA295 | ❌ no proofs or model checker; the sync code cites one paper296 | ❌ none in the repo; docs cite the Fugue, Eg-walker and tree-move papers297 |
| Developer surface | ||||
| Errors | ✅ SyncomError with a code (40 codes); TypeError for a wrong argument | ❌ plain Error messages, no classes or codes | ⚠️ JS throws RangeError with messages; Rust has a typed AutomergeError enum298 | ❌ JS: thrown message strings, no class or code; Rust: typed LoroError299 |
| Inspector and debugging | ⚠️ no inspector; stats(), an injected logger, OpenTelemetry spans and metrics | ⚠️ logUpdate, logNode, obfuscateUpdate; Yjs inspector site300 | ⚠️ no inspector; automerge-cli (examine, export), stats, dump, anonymize301 | ✅ Loro Inspector (web); setDebug(), debugHistory() |
| Anonymized copy for bug reports | ❌ none | ✅ obfuscateUpdate replaces content, keeps the CRDT structure | ✅ anonymize(doc) replaces keys, values, actors and metadata, keeps history shape302 | ⚠️ redactJsonUpdates blanks content in JSON updates over a version range |
| UI framework bindings | ❌ none; Cursor.watch() and Record events to wire one | ⚠️ third-party only: react-yjs, SyncedStore, valtio-yjs, immer-yjs | ✅ react hooks, Svelte store, Solid primitives (automerge-repo packages); no Vue303 | ✅ loro-mirror (state mirror) and loro-mirror-react304 |
| Documentation | ✅ VitePress site: guides, API reference, architecture, benchmarks | ⚠️ docs.yjs.dev: guides, API, ecosystem, internals, for v13305 | ✅ automerge.org: tutorial, data model, merge rules, internals, cookbook, typedoc API306 | ✅ loro.dev: tutorials, concepts, full JS API reference, benchmarks, blog |
| Test suite | 958 node:test tests, 9 tsd files; CI on Node 22, 24307 | 338 cases in 16 files (lib0/testing); CI on Node 26 | Rust ~730 tests + proptests, JS 351, WASM 160, automerge-repo 711; GitHub CI308 | ~2,000 Rust tests, 472 JS cases, 396 loro.js cases, fuzz, loom; CI |
Notes
- Branch
features/enhance-crdt-engine. 0.0.1 is a development placeholder, so there is no npmlatestto differ from. - v14 renames the package to
@y/yand publishes release candidates under the npmbetatag; the README still saysnpm i yjsand documents mostly the v13 API. - The clone's main already carries unreleased APIs under the 3.5.0 label (
maskAuthor/revealAuthor), which the npm 3.5.0 build lacks. automerge-repo is read at 3ab7c33 (2.6.0-alpha.5); npmlatestthere is 2.6.0-alpha.3. - The shallow clone has no CHANGELOG or tag history, so the first release year is not shown; the YATA paper it implements dates from 2016.
- automerge-repo, which the sync, storage and framework rows lean on, has an alpha (2.6.0-alpha.3) as its npm
latest. - Sync, the storage backends, undo and branches ship inside the one package; there are no editor bindings to license.
- One core file,
text_diff/myers.rs, is copied from thesimilarcrate under Apache-2.0 (its LICENSE sits beside it). The ProseMirror/CodeMirror bindings and Keyhive are outside the clones. - The licenses of
loro-prosemirror,loro-codemirrorandloro-mirrorare not in the clones and were not checked. - yrs/y-crdt is co-maintained by Bartosz Sypytkowski, Kevin Jahns and John Waidhofer.
- The same repo ships
loro.js, an experimental pure-TypeScript port (0.4.0) that targets wire and API compatibility without WebAssembly. @alexify/wrpcis a devDependency only: the application hands its client in.- In 13.6.33 (npm
latest) the one dependency islib0^0.2.99. - The default
importinlines the WASM as base64 and instantiates it synchronously; Node reads the file; thebrowsercondition needs bundler WASM support (webpackasyncWebAssembly, Vitevite-plugin-wasm+ top-level await);/slim+initializeWasm()is the manual async path. The repo has no CSP note; on Cloudflare Workers a doc cannot be created outside a handler. - The default browser entry fetches the .wasm with a synchronous XMLHttpRequest. With the
bundlerentry, Vite needsvite-plugin-wasmandvite-plugin-top-level-await, and plain esbuild/Rollup must useloro-crdt/base64or copy the asset. The repo says nothing about CSP. - yrs 0.28 follows the v13 type set (
Text,Map,Array,Xml*) and both update formats, not v14Y.Node. - Python (automerge-py) and Swift (automerge-swift) are automerge-org projects outside the clones; R is community-maintained (CRAN); Go (automerge-go) is named in the 2.0 blog;
samodis a Rust automerge-repo. - Other bindings are built from
loro-ffi, which is not in the clones. The repo also holds a MoonBit codec for the binary format (codec only) and the pure-TSloro.js. - Nothing in the repository tests or claims Deno, Bun or React Native. The browser half needs Web Crypto with Ed25519, and OPFS or IndexedDB (
@alexify/syncom/idb). - Deno, Bun, React Native and workers are not mentioned. In 13.6.33 (npm
latest) the package ships CommonJS and ESM builds and needs Node ≥ 16. - No
enginesfield; automerge-repo needs Node ≥ 22.13. Deno has WASM tests but no CI job; React Native needs Metro package-exports config plus manual init; Bun is not mentioned. - The repo does not mention React Native / Hermes. The sync browser entry needs
XMLHttpRequest; elsewhere use thenodejs,web,base64orbundlerentry. - A schema types the collection names (
storage.Post), not the shape of a record:get()resolves tounknown. - The type parameter is a compile-time shape only:
$nodechecks the name at runtime, not the content. - The WASM layer's types are a hand-written TS block in
automerge-wasm/src/lib.rs, copied towasm_types.d.tsat build.load<T>does no runtime check ofT. - Every read and write returns a promise; a write resolves once its op is in the log.
- Every core call is synchronous except WASM init. A doc handed to
changebecomes outdated and cannot be changed again (clonefirst). automerge-repo wraps it in aDocHandle(handle.change, events);repo.find()is async. - Each top-level field of a record is one kind — register, counter, set, list, text or tree. A replica is a storage directory: records, the op log, the chain, the catalog.
- Loading and syncing each subdocument is the provider's job; the docs say official providers treat subdocs as separate entities.
- The core holds one document per instance; there are no subdocuments.
- The Loro Protocol multiplexes many documents (rooms) over one WebSocket, but the app still names and keeps them. A blog post says the team's directory CRDT (Flock) will be open-sourced later; it is not in the repo.
- v14 folds
Y.Map,Y.Array,Y.Textand the XML types intoY.Node. In 13.6.33 (npmlatest) they are separate classes. Neither has a counter, set, tree or movable list. Tablesurvives only as a legacy alias of map. No tree, movable list, set or XML type.- A nested object or array is one register, and a list holds plain JSON values. Structures compose by id: a tree's nodes and a document's blocks are record ids.
- A node instance lives in one place; to place it elsewhere, insert a
clone(). - A text holds characters and inline block-marker maps only.
from([1, 2])gives a map with keys '0' and '1' (probed). - A child container gets a new id from the op that inserts it. Inserting an already-attached container copies it under a new id; no container moves between parents (tree nodes excepted).
- A record is created by
insert()(a minted UUID) orset(id). Two nodes that create one field with the same kind at once merge into one field — there are no container ids; twoset(id)of one id are whole-record writes and the newer wins. - A v14 root has one kind, so replicas cannot disagree on its type. In 13.6.33 (npm
latest)getMap('r')on one replica andgetText('r')on another each see only their own kind's content (checked by running it). Roots cannot be deleted. - Two replicas that each assign
d.list = []hold two distinct lists: one wins by op id, the other's items are hidden (reachable throughgetConflicts). The docs say to sync the initial change first or hard-code its bytes. - Root ids are
cid:root-<name>:<Type>, so two replicas get the same root and a map named x never clashes with a text named x. A child fromsetContainergets an op-based id;ensureMergeable*(key)gives a deterministic id so lazy concurrent creation merges. - Checked in a probe: a
Datereads back as an ISO string, aUint8Arrayas an object of indexes,NaNandInfinityasnull, a field set toundefinedis dropped, aBigIntthrows aTypeError. There is no binary type. - How
Dateandbigintround-trip depends on lib0 1.xwriteAny, which is not cloned. In 13.6.33 (npmlatest) aDatereaches peers as{}while the writer still reads aDate, and abigintwraps to signed 64 bits. bigintis accepted (uint above i64 max) and reads back as a number when safe, asbigintfrom 2^53; NaN and Infinity round-trip as f64;undefined, functions, symbols andMapthrowRangeError; class instances are stored as plain maps (all probed).- A
Dateis stored as{}, bigint, functions and symbols throw, a JSMapbecomes a map. Integers within ±2^53 are stored as i64, other numbers as f64. - Two nodes changing different keys of one nested object at once keep one side (probe confirmed);
update({ 'a.b': 1 })writes a field nameda.b. - Inserted JSON is not copied; in development mode it is deep-frozen, and the docs warn against mutating it.
- Assigning a whole new object creates a new map; a concurrent edit inside the old one is lost (probed:
o = {a: 9}beat a concurrento.b = 3). - Where the winner arrives the loser's state is dropped; where the winner is held the loser's op is refused (
OP_REFUSEDerror event). The loser's ops stay in the log, sohistory()on a node that applied them still shows them. - In v14 both sides are
Y.Nodes, so this is the ordinary register conflict. The loser is reachable only from a snapshot taken withgc: false. - The next write to the key resolves the conflict.
- The losing container is hidden (
isDeleted()is true) butgetContainerByIdstill reads it. WithensureMergeable*, the losing kind's state resurfaces if that kind is ensured again. - Validates local writes (defaults folded into the op) and every peer's op (an invalid one is skipped here, still relayed); schema versions with a lens and
migrate(). - The JSDoc says to validate content as a value, e.g.
delta.$delta(conf).check(node.toDelta())(lib0 schema); nothing checks remote changes. - The docs name Cambria as research that is not implemented.
- loro-mirror (official, not in the clones) maps a declared schema onto containers; the docs do not describe it rejecting remote data.
- Not an option: the pair lives in
keys/, and anode.jsonnaming another key is refused (IDENTITY_MISMATCH). - Every new
Doc(each session) gets a new id, so state vectors grow with sessions. - One actor's changes must be sequential. 3.5.0 adds an optional
author(hex) mapped to one or more actors. - A host refuses a hello from its own id (409
SELF), and a node whose stream a host holds more of is toldREWOUNDand moves past it. Two live copies writing at once mint the same seqs: whichever op arrives second under a seq is dropped as a duplicate. - Ops already written under the shared id are dropped as known by the other side, so the replicas end up different for good; the FAQ warns of permanent corruption.
- Probe: two clones given one actor each made a change;
merge,applyChangesand sync all threwRangeError. Nothing regenerates the id, and the two histories cannot be merged. - Detection happens only where the two histories overlap, at import; the import is refused and the document left unchanged. The docs make avoiding reuse the app's job.
- A claim the origin signs, not a proof of who the user is.
- In 13.6.33 (npm
latest) the experimentalPermanentUserDatamaps them inside the document. - Stored in an actor's first change and kept through merge and save; changing the author mints a fresh actor.
- Vector keys are
origin@scope; list and text elements are namedts:offset:origin, runsts:origin; a log position is an event id<epoch>.<seq>. - Deletes get no id: the clock counts inserts only.
- Registers order by
(ts, origin). The clock never runs backwards nor behind a stamp it has seen, so convergence does not depend on clocks agreeing — only who wins does. - Concurrent order comes from YATA's
origin/rightOrigin, ties broken byclientID. The README calls ids Lamport timestamps, but a client's clock never jumps on receive. - Reading at a version uses a per-actor vector of max op counters.
- Skew changes who wins, never whether nodes agree; a stamp more than
sync.maxSkew(24 h) ahead is refused as malformed. - Time is used only locally, by
UndoManagercapture grouping and awareness timeouts. time: undefinedstores 0 (probed).- Off by default; a change then takes the largest timestamp seen. Timestamps are forced ascending and decide whether consecutive commits merge into one change.
get(id, { at })takes an event id ('<epoch>.<seq>', a position in this node's log), a stamp,{ vector }or{ tag }.- The README says the state vector describes local state and is not used to track causality.
- JSON-able as is; automerge-repo also encodes heads for URLs (
UrlHeads). - An app compares decoded state vectors itself.
getMissingDepsandgetChangesSincehelp derive the rest.- Also
clearAttrs,attrKeys,attrValues,attrEntries,attrSize,forEachAttr. In 13.6.33 (npmlatest) the map isY.Mapwithset/get/delete/has/clear. - A write that saw the other always wins over it; only truly concurrent writes fall to the
clientIDorder. - With
gc: falsea lost value stays in the store and shows in an older snapshot, but there is no conflicts API. - Patches carry
conflict: true; the next write to the key clears it. set()is a reset: a concurrentupdate()stamped after it keeps its fields, one stamped before loses them.update({ k: undefined })makes no op (probe).delete(id)tombstones the record; a newer write revives it with only the newer fields.- A delete removes only the value it saw; a concurrent set is a new value it never covered.
- The reset drops what is stamped below it in every kind: registers, counters, set tags, list and text runs, tree moves.
- There is no whole-document clear, and roots cannot be deleted.
- Reassigning creates a new object, so concurrent writes into the old one are dropped.
- Not a single reset op: keys a peer adds concurrently survive, and each deleted key races a concurrent set by LWW.
deleteRootContainerempties and hides a root. - In a probe a whole-record write merged with a concurrent update read
{ only, later }on one node and{ later, only }on the other. A set field reads sorted by element JSON. toJSON()returns a plain object, so integer-like keys come first there.- Probe: after a change
Object.keysreadz, a, m; afterloadit reada, m, z. Integer-like keys follow JS object rules. - A number in an attr is a register: concurrent increments lose all but one.
- An
incthat would take its origin's sum pastNumber.MAX_SAFE_INTEGERis refused as a bound (detail: 'counter'), the same on every node; the sums add up in origin order, so fractions read alike everywhere. increment(1.7)adds 1 andnew Counter(1.5)stores 1 (probed). The sum is a plain i64+with no overflow check.- A peer's register write takes the field whatever its stamp (locally
update()of a counter throwsFIELD_KIND). A counter one origin incremented both before and after a concurrent reset does not converge — documented and left so. - An increment counts only while the counter's put has no other successor (probed: reset to 0 ‖ +3 → 0; delete ‖ +3 → key gone).
- Rust
clear()decrements by the observed value, so concurrent increments survive. Deleting the map key hides the counter; withensureMergeableCounterre-ensuring resurfaces it with those increments. - A remove names the add stamps its node observed; elements are told apart by their JSON text and read sorted.
- The docs suggest no model; keys with a dummy value work because a concurrent set beats a delete.
- In 13.6.33 (npm
latest) the list isY.Array. - The placement rule is adapted from
fugue-simple(MIT); the movable list follows Loro's MovableList. - A formal model of YATA exists in the third-party lean-yjs project, cited by the README.
- Tested for forward runs, backward runs (typing at the start) and the Yjs-interleave case (
xab, neveraxb). Two runs anchored after one element may swap order — the FugueMax difference. - Not tested in the repo. Run on 13.6.33 (same
Item.integrate): two writers typing forward or backward at one spot never interleave, but with three replicas a concurrent insert can land inside a right-to-left run. - Probe: per-keystroke appends
abc‖xyz→xyzabc; per-keystroke prepends →xaybzc(lists too). The docs admit reverse insertion is not preserved. - Many elements in one op only through
doc.applyDiffwith a list delta. - The value follows the element through a concurrent move.
- Two replicas replacing one element concurrently leave both new values in the list.
getConflicts(list, i)shows both; an assign concurrent with a delete revives the element (probed).- Plain
LoroListhas no set. - At the newer move's place (tests and probe).
- A move existed only on a side branch of yjs (y-crdt README); main has none. A moved element is delete plus re-insert.
- The removal took the old slot, the move made a new one; a removal made after the move takes the element away (probe confirmed).
- A concurrent
setand delete go the other way: the element stays deleted. ["ab", 3, "c"]: items alternate with tombstone lengths (list-positions' form); an extension adds no key to the record.- Deleted runs and garbage-collected ranges merge too.
- Text, embeds and child nodes can share one node's children. In 13.6.33 (npm
latest) the type isY.Text. ImmutableStringholds an atomic, non-collaborative string.- yrs defaults to UTF-8 byte offsets and can be set to UTF-16 (
OffsetKind). - The WASM build hard-codes UTF-16; JS has no switch.
- A probe inserting at index 2 of
a😀breada\ud83dZ\ude00b; the docs leave it to the caller. - The document stays valid, but the character is lost on every replica.
- Probe on
a😀b: insert at 2 lands after 😀,splice(1, 1)removes 😀 whole; a lone surrogate inserted becomes U+FFFD. - Diffed inside the mutation queue after the common prefix and suffix; past 1 000 edit steps it is one splice replacing the middle.
- Editor bindings compute the edits;
applyDeltatakes the result. - Marked beta; the docs warn it merges poorly when called rarely.
updateSpansdiffs block structure but not marks. - Docs warn
updatecan be slow past ~50,000 characters and point toupdateByLine; a timeout throws. - v14 uses lib0's delta (ops carry
type, attrs and nestedmodify), not Quill's JSON. In 13.6.33 (npmlatest)Y.Textreads and applies Quill-style deltas. - A text lives three times: the field, its runs in the record's metadata, the log. In the benchmarks below it types about 1 800 characters a second at the end, 390 at the start and 540 at random places of a text past ten thousand characters; the docs advise a long document as a list of block records.
- Benchmarks are for 13.6.11 (crdt-benchmarks, by the same author); B4×100 (10.5M chars) encodes to 16 MB. A per-keystroke update averaged 29 bytes in 13.3.0.
- Our run, npm 3.5.0 under Node, on
rust/edit-trace/edits.jsonin one change (5.6 s to apply). The 3.0 blog: pasting Moby Dick took 700 MB in 2.x and 1.3 MB in 3.0. - The benchmark ran on 1.0.0-beta.2; 100× the trace (10.5M chars) gave 21 MB and 66 ms parse. Sequences are capped at about 1.07e9 items.
- Formats are start/end markers in the list;
nullclears a key. - Mark values are scalars only (an object value throws).
- A key needs a style config; unknown keys throw unless
configDefaultTextStyleis set (bold, italic, underline, link, highlight, comment, code are preset). 'after'is the default;unmarkuses the reversed rule.- Read from the code (not run): unnamed formats at the spot are set to
null. In 13.6.33 (npmlatest)Y.Text.insertwithout attributes inherits the formats on its left. updateSpanstakesdefaultExpandandperMarkExpand.- Without
many, one value per name and character: the newest covering mark (Peritext'sallowMultiplerule). - Overlapping comments need distinct keys, e.g. one key per comment id.
- Probe:
comment=c1on 0–5 andcomment=c2on 3–8 read back as c1 0–3 and c2 3–8; overlapping comments need distinct mark names. - Two
commentmarks over overlapping ranges leave the overlap with one value; distinct keys likecomment:1andcomment:2(sharingcomment's expand rule) both survive. - A stacking mark goes only by its id:
unmarkById(). - An unmark is a null-valued mark, so per character the greater op id wins (probed: the concurrent unmark won on its range).
- A published rich-text schema (
type,parents,attrs,isEmbed) models nesting. - In 13.6.33 (npm
latest) these areY.XmlFragment,Y.XmlElement,Y.XmlText,Y.XmlHook; v14 still decodes them. - Bindings live in their own repos (not cloned); which already target v14 is not visible here. Third-party: slate-yjs, y-ace; BlockSuite and Superdoc bind natively.
- Both are named by the docs and blog but are not in the clones; no Tiptap, Quill, Monaco, Lexical or Slate binding is named.
- Both are official loro-dev packages outside the clones. A Quill example lives in the repo (
examples/loro-quill). - v14 adds converters between lib0 delta positions and relative positions.
- In JS cursors work on text only; the Rust core also takes lists.
- Inserting at the cursor's index pushes it right (probed).
'start'/'end'cursors stay pinned to the ends. - In a list a cursor anchors the slot, not the element: a move leaves it behind.
- It follows undo's re-inserted copies by default (
followUndoneDeletions), which only the undoing peer can see. - Resolving a deleted anchor replays history, so storing the
updatecursor keeps later lookups fast. - A separate package in the same repo, not exported by
loro-crdt. - Nested
Y.Nodes form a tree, but a node cannot move: it is deleted and a clone inserted. - The older move stands on every node; the lost move is kept and can count later. Locally a move under a descendant throws
CYCLE. - The loser's node stays where the other move put it; no event reports the dropped move.
- The tutorial says to call
enableFractionalIndex, but the code and 1.16.4 have it on by default (jitter 0). Concurrent inserts at one spot can interleave, which the docs accept for trees. - A move into a subtree removed concurrently is hidden with it (probe).
- For nested nodes: an insert into a concurrently deleted node is deleted on arrival; undo can re-create a deleted node.
- A child moved out of a deleted subtree comes back. The deleted node itself cannot be moved;
revertToand undo re-create it with new ids. update()writes many fields of a record as one op.applyPatches()rehearses its ops and lands all or none on a refusal, but each stays its own op and event for observers and peers; nothing spans records.- No rollback: if
fnthrows, the edits made so far stay and are sent. - Rust also offers an explicit
Transactionwith commit and rollback. - Not ACID: there is no rollback, and an op that throws leaves earlier ops in place. Consecutive local commits can merge into one change (
setChangeMergeInterval, default 1000 s). open({ defer })lets record files lag; the log is written at once.- Nested
transactcalls join the outer one; the update is emitted when it ends. - No timer; a callback with no ops makes no change.
- Change metadata exposes
extraBytesread-only; an author is stored in its actor's first change. - No custom fields beyond the message string.
subscribePreCommitcan rewrite the message and timestamp. sourceis taken byapplyPatches(),restore()andcompact()and set byrecord.save(); the undo manager tells its own ops by it.viaonchangenames the peer an op came from.- Origins reach observers, the
updateevent andUndoManager.trackedOrigins. - Sources include
change,merge,applyChanges,receiveSyncMessage; the changemessageis replicated, not local. - In JS
import()takes no origin (Rust hasimport_with). - Third-party
y-utilityoffers undo across docs, not atomic changes. - Runs inside the mutation queue before the op is stamped; an entity validator then folds defaults into the op.
- Observers may add edits in a follow-up transaction, but cannot cancel one.
- A skipped op is still logged and relayed. A host refuses outright what is out of scope, malformed, skewed, forged, or unsigned under
'require'. - The threat model calls server-side update filtering an antipattern and suggests a separate fork for untrusted writers.
- No per-change rule. On main only (unreleased),
maskAuthor(doc, author, heads)hides an author's later changes in this replica's view. - The doc also emits
beforeTransaction,beforeObserverCalls,afterAllTransactions,updateV2,subdocs. - No per-container observers in the core; automerge-repo
sub()handles get scoped events. - A
Record'supdatecarries(data, delta); no encoded bytes. - In 13.6.33 (npm
latest) map events also list each key'soldValueinevent.changes.keys. - No raw bytes in the event;
getLastLocalChange()returns them. - No old values, except a tree move's old parent and index. Update bytes come from
subscribeLocalUpdates. - One per field by kind (
put,inc,add,remove,splice,treeMove…); a text's marks are not diffed. node.deltais a maintained live copy of the deep state.- It returns a corrective delta when part of the input addressed rendered-but-deleted content.
- A map-key
delpatch throws ('index is not a number for patch'), so round-trips that delete keys fail (probed). getPathTo(parent, child)computes a path for an event target.- None in the core. The docs' Refs page (
handle.ref(),findRef) describes an API the repo source at 3ab7c33 no longer has. - Listeners are contained: a throw is logged and never fails the write.
- Edits made by observers run as further transactions, one after another.
- Retention compacts sealed segments (
retention.ops,retention.days); a dialing node keeps its own ops until its hosts hold them. The record keeps deleted text only as lengths. - The doc keeps current state plus tombstone ids. With
gc: falsedeleted content stays, but not when or by whom. - Per record, newest first, 50 by default;
log.entriesOf(id)gives the raw entries. - Per-client insert ranges are available (
createInsertSetFromStructStore), not grouped into changes. getHistoryandtopoHistoryTraversalare@hidden; automerge-repohandle.history()lists heads in topological order.- v14 adds
SnapshotRendererto render the doc as it was. - Once compaction ran, a version is exact only from a reset stamped above
droppedTs, elseHISTORY_GONE; the cost is oneapplyOpper retained op of the record. A tag costs a copied file per record changed after it. - A past version cannot be named afterwards: deletes carry no clock, so only snapshots the app kept can be restored.
viewthrows if a head is unknown.- Marks are not diffed, and a list's moves come back as splices.
- Arbitrary versions need docs for both (e.g. from snapshots). In 13.6.33 (npm
latest) diffs come fromY.Text.toDelta(snapshot, prevSnapshot). diffPath(experimental) limits it to one object.- A text's marks are not restored, since they are not diffed.
- No
revertTo(version); the app computes the ids changed since a version, and the code warns it may fail with GC on. - That recipe threw on a map-key deletion in our probe.
- Local to the node: tags do not replicate.
- Yjs records only
clientIDs; mapping changes to users is collected by the app or server, e.g. from each update'screateContentIdsFromUpdate. In 13.6.33 (npmlatest) only the experimentalPermanentUserData. - 3.5.0 maps authors to actors (
getAuthors,getActorsForAuthor); op ids name each value's actor (getConflictskeys). - Attribution is by peer id; map peers to users yourself (e.g. with
subscribeFirstCommitFromPeer). - Before what compaction dropped a version is
HISTORY_GONE, and a peer behind the floor bootstraps from a snapshot.compact()lays a text's marks again; it names the elements anew, so old cursors readnulland a stacking mark has a new id. - No shallow snapshot. A collected doc can no longer restore snapshots.
- The trimmed doc cannot check out before its root, and rejects updates from peers whose edits are based on an older version.
- A purge wins at any stamp; signatures and digests stay so the headers still verify, and the tags drop their copies. Per record, not per field; a peer that never comes back keeps what it had.
- A peer with
gc: false, or stored raw updates not yet compacted, keep the bytes;mergeUpdateskeeps the first encoding it sees. anonymize()makes a scrubbed copy for sharing, not an in-place purge.- Only exported bytes are cleaned (reload from them), and deleted tree nodes' data stays because a later move can revive them.
redactJsonUpdatesblanks content in exported JSON updates. - The fork has its own key pair and log, and forks the whole storage, not one record.
- In 13.6.33 (npm
latest) there is nocloneDoc:applyUpdate(new Y.Doc(), encodeStateAsUpdate(doc)). - Needs
gc: falseon the source. - Joins as state when the source compacted past what the other needs (
joined: true). - Rust exposes the same as
isolate(heads)/integrate(). - Edits made while detached get a new peer id per checkout and form a concurrent branch; imports go to the log only until
attach(). - New in v14; the threat model recommends a separate doc for suggestion-only users.
- On main at 5bae775 only: not in the npm 3.5.0 build.
- An update applied without an origin has origin
nulland is tracked as local; providers pass themselves as origin. - Changing the peer id clears both stacks.
- A peer's edit of the same list or text blocks undoing it; a whole-record step with a conflict is skipped whole.
ignoreRemoteAttributeChangeslets undo overwrite a peer's attr change.- Snapshots travel as JSON pages, streamed as NDJSON on binary-capable links. No binary encoding.
- Each chunk: magic
85 6f 4a 83, 4-byte checksum, type, length. Sync messages are v1 (0x42) or v2 (0x43); v2 may carry a whole document. - yrs reimplements both formats, a second implementation to read against.
- State without history, tombstones included;
at(tag).snapshot()pages a past state. - Yjs keeps no history to leave out; deleted content is included only with
gc: false. toJS(doc)gives the plain JSON value.log.since(eventId)is the public reader; the selection by vector (src/outbound.js) is internal.- The whole delete set always goes along.
- Ops need no merging: a batch is a list deduplicated by
(origin, seq), andapplyOp(entry, op)is pure too. - v14 adds
intersectUpdateWithContentIdsand marksdiffUpdatedeprecated. - automerge-cli
mergeloads several files and writes one compacted document. decodeImportBlobMeta(bytes)reads a blob's versions and mode without a doc.- Imported back with
mergeSnapshot()andapplyRemote(ops). - automerge-cli
examineprints changes as JSON;export/importmove state, not changes. - A catalog file indexes ids, and a sequence's index is cached per entry; no lazy or columnar encoding.
- Subdocuments are the way to load parts on demand. B4 parses in 39 ms (13.6.11, crdt-benchmarks).
- Load verifies heads by default (hidden
uncheckedskips it). The 3.0 blog cites a 17-hour load cut to 9 s. - The rules for after publication: slots only appended, a new op type bumps
PROTOCOL; a host refuses a hello of another version (400). - v14 reads v13 updates; it writes new nodes with the XML type refs, so a v13 reader sees them as
Y.XmlFragment/Y.XmlElement. - No written policy beyond 'we try and respect semver'; 0.11 began rejecting documents with misordered marks (
rescuerecovers their value). - The decoder error says the format is backward compatible but not forward compatible; container kinds a reader does not know are kept as Unknown.
- v14 tests use
@y/protocols1.0.6-rc.1 (not cloned). - Based on arXiv:2012.00472; it assumes a reliable in-order stream.
- The protocol repo is not in the clones; this comes from its announcement post.
- The subscription resumes from the saved last event id; a node below the host's log floor bootstraps from a snapshot. Duplicates are dropped by vector.
- A persisted
SyncState(encodeSyncState) keeps the shared heads across sessions; when over a third of the doc is due, v2 sends the whole compressed doc. - Across origins, a list or text op whose anchor has not arrived is held as pending work, 4 096 per field.
- v14 also stores gaps in one client's stream as skips. Pending data is re-sent by
encodeStateAsUpdate. load()throws on missing changes unlessallowMissingChanges.- Messages are JSON values over call and subscribe, not raw bytes; WebRTC goes through wrpc's peer links. No BroadcastChannel adapter.
- Providers are not cloned; many third-party ones exist.
- No official WebRTC adapter in the clones.
- The host checks
authorize, scopes, push limits, op shape, skew and signatures, and runs its own validators and hooks. - Not cloned, so described from the README only; the threat model calls server-side update filtering an antipattern. Third-party: Hocuspocus, y-sweet, y-redis.
- The public
wss://sync.automerge.orgis not meant for production; Keyhive (alpha) adds an access policy for Subduction servers. - It validates only what its
authenticatehook checks; persistence goes throughonLoadDocument/onSaveDocument. Not in the clones. - Both store updates and compact them after 500.
- Safe for concurrent writers: chunks are keyed by heads or hash, and compaction deletes only what it loaded.
Presence(heartbeats, per-peer state) ships in the 2.6 alphas.- A subset of records, never part of one.
- No sync of a subtree per peer; the FAQ splits data into docs where permissions differ.
- Within a document everything syncs.
- Optional (
chain: false), and each node's chain is its own audit, not shared. Ops are named by(origin, seq), not by hashes. - The
subscribePreCommitdocs show building a Merkle DAG yourself by hashingexportJsonInIdSpaninto the commit message. 'verify'(the default) still takes unsigned ops; only'require'refuses them. The signature covers the header and a salted payload digest, so a scrubbed op still verifies.- Keyhive (alpha) signs sync traffic and contact cards, not individual changes;
emptyChangeis suggested for signing merged heads yourself. - Log segments carry no checksum: a torn one is skipped at open and counted in
droppedTs. - A payload
applyOpcannot admit — of any op type — isOP_REFUSED(malformed) and logged as a no-op; a page that throws is retried from its cursor. - Items are integrated before the delete set is read, so a truncated update leaves its items in the doc.
- Probe: a change with an op on an unknown object (built with
encodeChange) threwPanicErrorfrom Rust, viaapplyChangesand via a sync message; the same doc then panicked on its nextchange. - Many of these paths trapped the WASM instance until 1.16.4. A Rust panic still kills the instance (e.g.
configTextStylewith ':' in a key). pendingMax4 096 per field (snapshot blocks too), a run 2^20 and a field 2^22 slots, trees 512 levels and 1 024-character indexes, an origin's counter sum ±2^53−1,maxSkew24 h, per-peeropsPerPush,bytesPerPushandopsPerSecond. Nothing else caps a set's size or a tree's move count.- The decoder sizes arrays from wire counts; the threat model leaves size limits to the app.
- A libFuzzer target and crash fixtures (
too_many_ops,overflow_in_length…) cover loading. - Nothing bounds the size of an update or the pending queue; a WASM out-of-memory traps the instance.
- Third-party: secsync, Matrix-CRDT, managed services.
- The docs say Keyhive is still in development and details will change.
- Also every arrival order of small op sets in the op, sequence and tree tests. The fuzz compares the blocks and move logs, not only the reading; one seed each.
write_frontier_paritydrives two peers and checks patches against hydrate and reload; the batch tests apply random edits from forks; the repo fuzz (3 repos, 100k changes) is a script, not CI.- The README says preservation and commutativity are proven so far and that the paper's pseudocode has errors.
sync.rsnames arXiv:2012.00472 as the protocol's basis; nothing about the CRDT is verified in the repo.- The repo runs loom, a model checker for thread interleavings of the Rust doc, which tests thread safety rather than convergence.
- Rust panics surface in JS as
PanicError. - In JS a
LoroErrorbecomes a plain string (no class or code); a few paths throwError, and a Rust panic traps the instance. - Other devtools listed (y-sweet, Liveblocks, Velt) are third-party.
- automerge-repo logs through
debug(DEBUG=automerge-repo:*). - Best-effort, not cryptographic: the change graph, types, sizes and string lengths remain.
@automerge/reactbundles the hooks with the repo and common adapters.- Official loro-dev packages outside the clones; no Vue, Svelte or Solid binding is named.
- v14's
Y.NodeAPI is in JSDoc and two repo notes; the site and most of the README describe v13. - The README calls the Rust API low level and not well documented; the binary format has its own spec page.
- Coverage is enforced at 95/95/90/95 (lines, statements, branches, functions); a run here passed 957 with 1 skipped.
- CI builds and tests on Linux, macOS and Windows, runs the WASM and JS suites and a Node 18 packaging test; the C API has cmocka tests.
Speed
The four run the same text scenarios on the Benchmarks page, in a fresh process each, on one machine and in memory: one character typed at the end of a text, at its start, and at a random place, while the text grows past ten thousand characters. The other libraries are the published packages (yjs 13.6.33, @automerge/automerge 3.5.0, loro-crdt 1.16.4), not the cloned commits.
A syncom keystroke is an op: stamped, signed, appended to the log, applied to the record, and the record written back whole. That is why it is two to three orders of magnitude behind Yjs and Loro, and why its texts are for a field of a record rather than a whole document. The keystroke that extends the writer's own run at the end is the cheap one (about 1 800 a second); a keystroke at the start or in the middle rebuilds the field's index and runs at about 400 to 550.
The three CRDT libraries' whole packages, against syncom's whole entry. Automerge and Loro carry a WebAssembly module of 3.2 to 3.5 MB (about 1.1 MB gzipped); Yjs and syncom are plain JavaScript.
Which to choose
- Yjs for a collaborative editor in the browser: the most editor bindings (ProseMirror, Tiptap, CodeMirror, Monaco, Quill, Lexical), presence, providers for WebSocket and WebRTC, and a small, fast core. Its 14.0 release adds diffs, attribution and suggestions; check which bindings already support it.
- Automerge when every change must be kept and traced: a hash-linked history that every replica checks, readable conflicts, a sync protocol in the core,
automerge-repofor many documents with storage and network adapters, and bindings for Rust, C, Python and Swift. - Loro for the richest document model and the fastest large documents: movable lists, movable trees, rich text with per-key expand rules, time travel and
revertTo, shallow snapshots, undo that transforms over peers' edits, and lazy loading. - syncom when the data is many records rather than one document, and nodes must check what they receive: a peer's op is signed and validated, scopes decide what each peer gets, limits stop a hostile peer,
purge()erases a payload everywhere, and tags, branches and undo work over the whole storage. Its lists, texts and trees merge like Loro's, but only as fields of a record, and at a fraction of the speed. - Together. One of the three can hold the documents people type into, and syncom the records around them: who owns a document, its title, its tags, who may read it. A syncom field is not the place for the document itself: a field holds JSON, and a whole value written to it is one register, so of two concurrent saves one is lost.