Sitemap

The Secrets Behind Online Editors: What Happens During Collaborative Editing?

Yjs, CRDT, and Other Magic Words

--

Imagine this: you and a colleague are on opposite sides of the world, yet your cursors meet in a shared online document. You’re both inserting words into the same position or deleting a fragment of text that the other is editing at that exact moment. It seems like it should all descend into chaos — but instead, everything comes together into a single, logical version of the text. Despite the distance, the latency, and simultaneous edits, it all just works. And you’re not even waiting for your changes to sync with the server state. You simply edit the document and can trust that your changes will be applied.

Press enter or click to view image in full size

Behind this magic often lie CRDTs — data structures that make decentralized synchronization possible. I came across them myself while working on a collaborative online editor: CRDTs and the Yjs library quite literally saved my project from chaos and made synchronization seamless.

My name is Nikita Lykosov, I’m a frontend developer at Doubletapp, and I’d like to walk you through how this engineering magic works, step by step. Spoiler: it’s much simpler than it sounds.

G-Counter — The Simplest CRDT

Let’s imagine this scenario: Vasya and Nastya are sitting by a window counting cats in the yard. Vasya is in charge of white cats, and Nastya counts black ones. Each keeps their own tally in a notebook.

At first, Vasya counts 5 white cats, and Nastya counts 5 black ones. They look at each other and add up their numbers — there are 10 cats in the yard.

Then they each continue counting their respective cats: Vasya sees 3 more white cats, and Nastya adds 2 more black ones.

After a while, they compare their notes again and sum the results: Vasya now has 8 white cats, Nastya has 7 black cats, totaling 15 cats.

Their combined total is always correct, regardless of how often or in what order they compare notes. This approach underlies the simplest CRDT — a distributed counter called a G-Counter.

A G-Counter is a distributed counter that can only increment. Each participant in the system maintains their own count, and when exchanging data with other participants, they merge states. Here’s what it looks like in JS:

class GCounter {
constructor(id) {
this.id = id;
this.state = { [id]: 0 };
}

inc() {
this.state[this.id] += 1;
}

getValue() {
// sum of all counters in state
return Object.values(this.state).reduce((a, b) => a + b, 0);
}

merge(counter) {
// merge all counters from another G-Counter into the current state.
// if a counter already exists, take the larger value
for (const key in counter.state) {
this.state[key] = Math.max(this.state[key] || 0, counter.state[key]);
}
}
}

Each participant keeps their own copy of the counter and can only increment their own value. When states are exchanged, any two replicas simply merge their data by taking the maximum value for each identifier. The final sum will always be the same across all participants, no matter the order or number of merges — even if data is duplicated, the result won’t change.

What CRDT Rules Are Demonstrated in G-Counter — and Why Do They Matter?

A CRDT (Conflict-free Replicated Data Type) is simply a data structure that has the following properties:

Commutativity
merge(“Vasya”, “Nastya”) and merge(“Nastya”, “Vasya”) produce the same result.
For example, if two participants have the following states:
{“Vasya”: 2, “Nastya”: 0} and {“Vasya”: 1, “Nastya”: 3},
after merging, both will have {“Vasya”: 2, “Nastya”: 3} — it doesn’t matter who sent the data first.

const counterVasia = new GCounter("Vasya");
const counterNastia = new GCounter("Nastya");

counterVasia.inc(); // {"Vasya": 1}
counterNastia.inc(); // {"Nastya": 1}

counterVasia.merge(counterNastia);
counterNastia.merge(counterVasia);

console.log(counterVasia.getValue()); // {"Vasya": 1, "Nastya": 1} = 2
console.log(counterNastia.getValue()); // {"Nastya": 1, "Vasya": 1} = 2

Associativity
You can merge states in pairs in any order:
merge(merge(“Vasya”, “Nastya”), “Egor”) equals merge(“Vasya”, merge(“Nastya”, “Egor”)).

This matters when there are more than two participants — the result will still be the same.

const counterVasia = new GCounter("Vasya");
const counterNastia = new GCounter("Nastya");
const counterEgor = new GCounter("Egor");

counterVasia.inc(); // {"Vasya": 1}
counterVasia.inc(); // {"Vasya": 2}
counterEgor.inc(); // {"Egor": 1}

// {"Vasya": 2, "Nastya": 0, "Egor": 1}
const val1 = counterEgor.merge(counterNastia.merge(counterVasia)).getValue();
// {"Nastya": 1, "Vasya": 2, "Egor": 0}
const val2 = counterEgor.merge(counterVasia.merge(counterNastia)).getValue();

console.log(val1 === val2); // 2 === 2

Idempotence
If the same state is merged multiple times — merge(“Vasya”, “Vasya”) — nothing changes. So, if a participant accidentally receives a duplicate state, the total won’t increase.

const counterVasia = new GCounter("Vasya");

const counterNastia = new GCounter("Nastya");
counterNastia.inc(); // "Vasya": 0, "Nastya": 1

counterVasia.merge(counterVasia.state);
console.log(counterNastia.getValue()); // 1
counterVasia.merge(counterVasia.state);
console.log(counterNastia.getValue()); // 1

Let’s break down how these three properties help Vasya and Nastya always get the same result, even if they count cats independently and exchange their results in a different order.

  • Commutativity means that whether Vasya first merges his data with Nastya’s or vice versa, the final number of cats will be the same.
  • Associativity allows you to bring in a third participant (say, Egor), and it won’t matter in which order their data is merged — Vasya with Nastya then Egor, or Nastya with Egor then Vasya — the final total will always match.
  • Idempotence protects against errors when Vasya and Nastya accidentally exchange the same data more than once. Even if Vasya merges Nastya’s state twice, the total number of cats won’t increase — the result stays correct.

Altogether, this means Vasya, Nastya (and even Egor) can share their tallies as often as they like, in any order, and they’ll all always end up with the same number of cats. That’s exactly why counters like G-Counter work so well in distributed systems: consistency is achieved automatically, without centralized control or complex coordination between participants. This is why CRDTs can confidently claim convergence to a single state. Thanks to this, we can safely use CRDTs in distributed systems, where consistency happens by design.

Arrays

Now that we understand how CRDT structures work, let’s try something more complex. Let’s look at how to build a CRDT array (or text).

In arrays, not only the set of elements matters, but also their order. If one of the participants inserts or deletes a character before you, your index will have already “shifted” and no longer corresponds to the expected position.

The solution is a structure called RGA (Replicated Growable Array):

  • Each element gets a unique identifier.
  • Insertion happens after a specific element (by its ID), not by index.
  • Deletion is implemented via a special flag, not by physically removing the element.
class RGAElement {
constructor(id, value, deleted = false) {
this.id = id; // unique ID (e.g., [timestamp, user ID])
this.value = value;
this.deleted = deleted;
this.next = [];
}
}

class RGA {
constructor() {
this.elements = {}; // id -> RGAElement
this.head = new RGAElement('head', null);
this.elements['head'] = this.head;
}

insert(afterId, id, value) {
const elem = new RGAElement(id, value);
this.elements[id] = elem;
this.elements[afterId].next.push(id);
}

delete(id) {
if (this.elements[id]) {
this.elements[id].deleted = true;
}
}

// Traverse to build the text
toText() {
const traverse = (id) => {
let res = '';
for (const nextId of this.elements[id].next) {
if (!this.elements[nextId].deleted) {
res += this.elements[nextId].value;
}
res += traverse(nextId);
}
return res;
};
return traverse('head');
}

merge(externalRGA) {
for (const externalId in externalRGA.elements) {
if (!this.elements[externalId]) {
const externalElem = externalRGA.elements[externalId];
// Insert missing element
const elem = new RGAElement(externalElem.id, externalElem.value);
this.elements[externalElem.id] = elem;
// Update deletion status
if (externalElem.deleted) {
this.delete(externalId);
}
}
}

// Merge and sort next pointers by ID
for (const id in this.elements) {
const elem = this.elements[id];
elem.next = [...new Set([
...elem.next,
...(externalRGA.elements[id]?.next || [])
])].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
}
}
}

After performing a merge operation, each next array will always be sorted in the same order. This ensures we know in advance which branch of the graph to descend into first.

Getting text from such a structure can be thought of as a classic depth first search (DFS), with the key difference being that the branch order is predetermined thanks to the sorting.

Let’s consider an example: initially, the structure contains the word “hellow”. Then, three users add their own branches: “world”, “John”, and “bye”. After that, someone adds an exclamation mark “!” after “world”. As a result, the next array of the letter “w” in “hellow” now contains three elements. When traversing the graph, we follow the order established during the merge.

Thus, when reconstructing the text, we get the following sequence: “hellowworld!Johnbye”

Press enter or click to view image in full size

Warning!

It’s worth noting that in real-world CRDT implementations, such as Yjs, an enhanced version of the RGA algorithm is typically used. These implementations include several key optimizations:

  • Reducing data structure size through efficient encoding of operations;
  • Garbage collection mechanisms to automatically remove elements marked as deleted;
  • Improved performance via optimized network communication.

To determine insertion order in distributed systems, logical timestamps — such as Lamport Timestamps — are often used instead of simple IDs. This algorithm provides partial ordering of events through synchronized counters between nodes.

So, what do we get from all this? Thanks to unique identifiers and the sorting of links, the RGA structure ensures that all participants see the same text after merging changes — regardless of the order or timing of their actions. At the same time, we retain the core CRDT properties: commutativity, associativity, and idempotence. This means the result of a merge is always the same, regardless of the order, grouping, or repetition of operations. All of this guarantees automatic conflict resolution and makes collaborative editing predictable and reliable.

Yjs: How Collaborative Editing Works in Practice

Of all the libraries that implement CRDT, I liked Yjs the most — primarily because of its performance. This article had a significant influence on my decision; I highly recommend reading it if you’re choosing an implementation for your own project.

Core Yjs Data Structures (Shared Types)

Yjs abstracts the low-level logic of CRDT through the concept of Shared Types. These are familiar JavaScript data structures — like Map or Array — but with “superpowers”: any changes made to them are automatically propagated to other peers and merged conflict-free.

  • Y.Text is the main Shared Type for working with text. It supports both plain and rich text.
  • Y.Array is used for ordered lists of elements.
  • Y.Map is for key-value pairs, where in case of conflict the last written value for a key “wins.”
  • There are also more specialized types like Y.XmlElement, Y.XmlFragment, and Y.XmlText, which are commonly used to build complex rich-text editors (for example, based on ProseMirror or Quill).

The Yjs Ecosystem

Yjs is not tied to a specific protocol. There are ready-to-use providers for various scenarios:

  • y-websocket for a client-server architecture,
  • y-webrtc for fully P2P synchronization without a central server,
  • and integrations with other protocols.

This flexibility allows you to choose the architecture that best fits your application. Some WYSIWYG editors even support it out of the box — for example, Lexical. In my experience, necessary features are often missing, so in my project, I ended up keeping just a few lines from y-websocket and rewriting everything else.

Synchronization Example:

import * as Y from 'yjs'

// Yjs documents are collections of shared objects
// that automatically synchronize.
const ydoc = new Y.Doc()
// Define a shared Y.Map instance
const ymap = ydoc.getMap()
ymap.set('keyA', 'valueA')

// Create another Yjs document (simulating a remote user)
// and make some conflicting changes
const ydocRemote = new Y.Doc()
const ymapRemote = ydocRemote.getMap()
ymapRemote.set('keyB', 'valueB')

// Merge changes from the remote document
const update = Y.encodeStateAsUpdate(ydocRemote)
Y.applyUpdate(ydoc, update)

// Notice that changes have been merged
console.log(ymap.toJSON()) // => { keyA: 'valueA', keyB: 'valueB' }

Conclusion

We’ve explored how CRDTs ensure data consistency in distributed systems without a central server — from the simple G-Counter to mechanisms for collaborative text editing. Yjs handles all that complexity for you, offering convenient Shared Types that make it easy to build collaborative applications.

CRDTs are a powerful solution for any task that requires conflict-free synchronization. If you’re facing similar challenges, Yjs could be the tool that makes your development much simpler and more efficient.

Try Yjs in your own project — it’s easier than you think!

Doubletapp has hands-on experience implementing CRDT and Yjs in frontend projects — from setting up client-side synchronization to integrating with existing architectures. We’re ready to help you assess the approach’s applicability, adapt libraries, and build robust client logic. If you’re looking for a reliable tech partner — we’d be happy to discuss your project.

Read our other stories:

Linkedin.com

--

--