NoobProMax

How it works

How CRDTs make real-time collaboration possible

The problem every collaborative editor has to solve, and the family of data structures that solves it.

8 min read

Open a document with someone else, both start typing on the same line at the same moment, and something slightly remarkable happens: both edits survive, in the same order, on both screens. Nobody gets a conflict dialog. Nobody loses a sentence. This is so normal now that it is easy to miss that it is a hard problem, and that for about thirty years the only production-quality answer to it required a central server running a large amount of very delicate code.

The answer most editors built in the last several years use instead is a conflict-free replicated data type, or CRDT. The name is forbidding and the academic literature is full of algebra, but the core idea is simple enough to explain properly in a few thousand words, which is what this guide tries to do.

Start with why the obvious approaches fail

Say two people, A and B, both have the text hello open.

Attempt one: send the whole document. A types ! at the end, producing hello!, and sends it. B simultaneously capitalises the h, producing Hello, and sends that. Both messages arrive at the server. Whichever lands second wins and the other person’s edit vanishes. This is “last write wins”, and it is why a shared network drive is not a collaborative editor.

Attempt two: send the change, not the document. Better. A sends “insert ! at position 5”. B sends “replace position 0 with H”. These do not conflict, and applying both to hello in either order yields Hello!. Progress.

Now try a case where they do interact. The text is hello. A inserts X at position 0. B inserts Y at position 1. A applies its own edit immediately, so A’s buffer reads Xhello. Then B’s operation arrives: insert Y at position 1. Applied literally, A gets XYhello. Meanwhile B applied its own edit first — hYello — then received A’s insert at position 0 and got XhYello.

Two users, same two operations, different results. The documents have diverged permanently, and every subsequent edit makes it worse.

The actual culprit: positions are not stable

The bug is not in the network or the ordering. It is that “position 1” means different things in different documents. An integer index is a description of where something is relative to everything else, and everything else keeps moving.

There are exactly two ways out. One is to keep using indices and fix them up on arrival — when B’s “insert at 1” shows up at A, notice that A has since inserted a character before position 1, and rewrite the operation to “insert at 2”. That is operational transformation, and it works, at the cost of a transformation function for every pair of operation types and a lot of subtle correctness obligations.

The other way is to stop using positions altogether. That is the CRDT route.

Identity instead of position

In a text CRDT, a document is not a string. It is a set of characters, each with a permanent unique identity, plus enough information to put them in order.

Give every insertion an ID made of the client that produced it and a counter that client increments: (A, 1), (A, 2), (B, 1). These are globally unique without any coordination, because no two clients share an ID prefix. Then, instead of “insert X at position 0”, an operation says “insert character X with ID (A,1), immediately after the character with ID (A,0) — naming a neighbour rather than a slot.

Now re-run the divergent example. A’s operation says “X goes after the start marker”. B’s says “Y goes after h”. Neither description is invalidated by the other, because h is still h and the start marker is still the start marker no matter what else was inserted. Both replicas apply both operations and both land on XhYello. The order the messages arrive in does not matter.

Concurrent inserts at the same spot

One case still needs a decision. If A and B both insert immediately after h, both operations reference the same neighbour, and something has to decide which goes first. If each replica decides locally on gut feeling, they diverge again.

The fix is to make the tie-break a deterministic function of data both replicas already have — typically comparing the client IDs, so (A,1) always sorts before (B,1) everywhere in the world. It does not matter which rule you pick, only that it is total, deterministic, and computed from the operations themselves rather than from arrival order or wall-clock time.

This is the whole trick, and it is worth stating plainly: the merge function is arranged so that applying the same set of operations always produces the same document, regardless of the order they were applied in or how many times each was applied. In the vocabulary of the papers, merge is commutative (order-independent), associative (grouping-independent) and idempotent (safe to reapply). A structure with those properties converges automatically. Nothing has to detect a conflict, because the design does not admit one.

Deletion, and the cost nobody mentions first

Deletion cannot simply remove the character, because a concurrent operation may reference the deleted character as its neighbour — and if that anchor is gone, the arriving operation has nowhere to attach.

So CRDTs do not delete. They mark the character as deleted and keep it, invisible, as a tombstone. It stays in the structure to serve as an anchor point forever.

This is the CRDT tax, and it is real. A document that has been heavily edited for months can carry far more tombstones than live characters. Naively implemented, a text CRDT can spend an order of magnitude more memory than the text it represents — which is exactly why early implementations had a reputation for being academically elegant and practically unusable.

Modern implementations attack this hard. The main technique is run-length encoding of the internal structure: when someone types a hundred characters in sequence, those hundred items have sequential IDs and identical neighbours, so they are stored as a single item with a length rather than a hundred separate ones. Since almost all real typing is sequential, this collapses the common case dramatically. Deleted runs compress the same way.

The practical upshot

The memory question is settled for the document sizes people actually edit. A mature CRDT library handling a large text document typically sits within a small multiple of the raw text size, and merges thousands of operations in milliseconds. The overhead is not zero, but it stopped being the deciding factor some years ago.

What this buys you

Convergence-by-construction has consequences that go well beyond “two people can type at once”.

  • The server stops being clever. Because merging is correct on every replica independently, the server does not need to order operations, transform them, or understand the document at all. It can be a dumb relay that forwards opaque byte strings. This is a significant reduction in the amount of code that has to be right, and it is what makes it feasible to put a collaborative editor on top of a generic realtime database rather than a bespoke backend.
  • Offline editing is the same code path. A client that has been disconnected for an hour is just a replica with a backlog. When it reconnects, it exchanges the operations each side is missing and both converge. There is no separate “sync and resolve” subsystem, because reconnecting after an hour and receiving a message 200ms late are the same operation with different numbers.
  • Peer-to-peer works. Nothing in the model assumes a star topology. Replicas can gossip in any arrangement as long as operations eventually reach everyone.
  • Undo becomes tractable. Operations have identities, so “undo my last change” means inverting a specific set of known operations rather than reasoning about what the document looked like at some earlier index — which matters a great deal once several people share an undo stack.

What it does not buy you

CRDTs guarantee that everyone ends up with the same document. They do not guarantee that everyone ends up with the document they wanted. Those are different claims and conflating them is the most common misunderstanding in this area.

If you and a colleague concurrently rewrite the same function two different ways, a CRDT will faithfully, deterministically interleave both rewrites into a single consistent mess that both of you can see. It merged correctly. The result is still garbage. Preserving intent is a user-interface problem, and it is solved by presence and awareness — showing people where everyone else is working so they do not collide in the first place — rather than by the data structure.

That is not a flaw so much as a boundary. It is worth knowing where the boundary is: presence is a separate system with separate rules, and it is doing at least as much work as the CRDT in making collaboration feel sane.

How this shows up here

Every NoobProMax workspace is a Yjs document. Yjs implements a text CRDT with the run-length optimisation described above, which is the main reason it performs well enough to sit behind a code editor where people hold down backspace.

The transport is Firebase Realtime Database, used in exactly the dumb-pipe role the model permits: it stores and forwards encoded update blobs and knows nothing about their contents. That property is also what makes end-to-end encrypted rooms possible — if the server never needed to read the updates, they can just as easily be ciphertext.

The clean way to see all of this is to open a workspace in two browser windows, put them side by side, and type in both at once. The behaviour you are watching is thirty years of distributed systems research reduced to “it just works”, which is the highest compliment a data structure can be paid.