At Ceros, we’ve been building a new no-code design tool for almost a year now, and under the hood, that means creating and maintaining a collaborative HTML-like document structure. We use Yjs for conflict-free syncing between users, but the way we initially represented our document turned out to have a serious problem.
This post walks through how we started, what broke, and how switching to fractional indexing gave us a more robust and future-proof solution.
First Attempt: Yjs XML Types
Yjs ships with built-in XmlElement
and XmlFragment
types. At first glance, these seemed perfect for modeling HTML. They give you a tree structure, which fits naturally with the DOM.
But they didn’t hold up in our case. In early prototyping, we quickly ruled this out.
In a no-code design tool environment, elements get moved (reordered and reparented) frequently. The XML types from Yjs do not support moving, and reparenting isn’t something they handle gracefully. We didn’t want to lose attribute changes from one peer if another peer moved the element, for example. Document size would also suffer over time, as moving an element recreated the schema while tombstoning the old data.
Second Attempt: Our Legacy ShareJS-Inspired Schema
We moved to a structure inspired by our earlier work with ShareJS. It split the document into two parts:
ElementSchema
: a map of IDs to element definitions (tag name, attributes, etc.)HierarchySchema
: a flattened tree where each parent ID maps to an array of ordered children
Example:
{
"elementSchema": {
"child1": { "tagName": "div", "attributes": { "class": "one" }},
"child2": { "tagName": "div", "attributes": { "class": "two", "aria-label": "Example" }},
"grandchild1": { "tagName": "span" }
},
"hierarchySchema": {
"root": ["child1", "child2"],
"child1": ["grandchild1"]
}
}
This worked well for a while. It was simple, readable, and fast. We even had beta users running on this structure.
But eventually, we hit a problem in testing.
What Broke: Deleting and Reordering
Here’s the edge case that caused real trouble:
If one user deleted a parent element while another user was reordering that parent’s children, the document could end up in a corrupt state.
Yjs handled the element deletion fine. It removed the parent and its children from the ElementSchema
. But the HierarchySchema
, being just arrays of IDs, might still include reordered children that no longer existed. Conflict resolution couldn’t handle this correctly. The result: orphaned elements, broken trees, and a few headaches.
We needed something that could handle updates to structure and order together, in one place.
Enter Fractional Indexing
Back when we first explored document models, we read about fractional indexing, specifically from some excellent blog posts by the folks at Figma:
At the time, it felt like overkill. It turns out, it wasn’t.
We moved to a new schema where each element tracks its own parent and position. This removed the need for a separate hierarchy schema.
{
"child1": {
"tagName": "div",
"parent": { "id": "root", "index": "aaaa1" },
"attributes": { "class": "one" }
},
"child2": {
"tagName": "div",
"parent": { "id": "root", "index": "bbbb2" },
"attributes": { "class": "two", "aria-label": "Example" }
}
}
The key idea is that index
is a string that sorts lexically and provides ordering within a parent. The id
and index
are updated atomically. If two users change the same parent’s children at the same time, Yjs merges the updates cleanly. There are no large collections of tombstones, and no dangling children.
This schema handled everything we threw at it. Even concurrent prepends or appends resolved into the correct order.
No Floats?
We didn’t use floating-point numbers for indexes. There are too many precision problems in JavaScript. Instead, we used the excellent fractional-indexing-jittered library, which generates sortable alphanumeric indexes like aaaa1
, aaab1
, and so on.
It let us insert between two existing indexes as many times as needed, without running into float limits. We also wanted a bit of randomness in the indexes (a.k.a. jitter), so two users inserting at the same time wouldn’t pick the same value. That turned out to be possible too.
The result was simple, stable, and clean.
Migration and Rollout
We had already built a concept of migrations into our Yjs sync layer. As clients pulled down documents from the API, we converted them to the new format just in time. This avoided any need for manual data migration or version pinning.
We wrote a simple migration to convert from the old schema to the new one. We accepted that any corrupt entries in the hierarchy schema (but not in the element schema) were lost, rather than trying to restore from backups. That’s the luxury of still being in beta.
It shipped smoothly, with our fingers crossed, and three weeks in, we haven’t looked back.
Final Thoughts
Fractional indexing turned out to be the right call. It fixed real-world conflict bugs, reduced document size, and simplified our logic.
We’re grateful for the documentation and tools already available around this model, which made the switch fast and low-risk.
If you’re building collaborative editors, and especially if you’re dealing with ordered trees, consider starting with fractional indexing. It might save you some time.