DEV Community

Cover image for CRDTs: How to Build Conflict-Free Collaborative Apps (And Why I Decided to Implement Them in Dart)
Mattia Pispisa
Mattia Pispisa

Posted on

CRDTs: How to Build Conflict-Free Collaborative Apps (And Why I Decided to Implement Them in Dart)

If you've ever used Google Docs, Figma, or Notion, you've seen seamless real-time collaboration in action. CRDTs are one of the techniques that can be used to build experiences like these.

Two people editing the same document at the same time.

One loses their connection.

The other keeps working.

A few minutes later, the connection drops back in... and, almost like magic, everything syncs up without anyone losing their work.

How is that possible?

There are several techniques to solve this problem. One of the most fascinating is the use of Conflict-free Replicated Data Types, better known as CRDTs.

In my research journey into distributed and decentralized systems, I deeply explored both the mathematical models for data consistency—like the foundational studies on CRDTs by Marc Shapiro—and the solutions to manage causality and event ordering without a central server, including Sandeep Kulkarni's Hybrid Logical Clocks.

From the intersection of these two concepts (convergent data structures and hybrid time management), the library I am developing for the Dart ecosystem was born. If you want to dive into the theoretical foundations of this project, you can find the complete list of academic papers and scientific texts I referenced in the references section of the official documentation.

In this article, I won't try to explain all the theory (that would take a book, not a blog post). Instead, I want to share why CRDTs exist, what problems they solve, and why I think they deserve much more attention. Finally, I'll introduce the system I'm building in Dart and share some live examples you can try out yourself.

So, don't expect an exhaustive treatise, but rather an introduction designed for those who have never heard of CRDTs, or have heard the acronym without fully grasping the concept. If you already work with distributed systems, much of this will sound familiar, but I hope to still offer you some fresh perspectives on the topic.


The Problem with Distributed Systems

Before talking about CRDTs, we need to understand the exact problem they are trying to solve.

One of the most famous definitions of a distributed system (taken from Wikipedia) is the following:

"A distributed system is a collection of independent computers that appears to its users as a single coherent system."

Behind that seemingly simple definition lies one of the toughest challenges in software engineering.

Imagine having an app installed on multiple devices.

sync_problem_diagram

They all display the same data.

What happens if two devices modify the same piece of information simultaneously?

And what happens if one of them is offline?

How do we ensure that, eventually, all devices see exactly the same state, regardless of the order in which they receive those updates?

This is the core problem CRDTs aim to solve.


A Very Simple Example

Let's imagine a shared counter.

The initial value is: 0

Now, the following happens:

  • Device A goes offline and increments the counter.
  • Device B also goes offline and increments the counter.

Both devices now locally see: 1

When their connections return, what should the result be?

The correct answer is obviously: 2

It sounds trivial.

But in a distributed system, it's anything but.

The two updates happened entirely independently, and neither device knew about the other's action.

A CRDT mathematically defines how these updates must be combined (merged) so that all nodes inevitably arrive at the exact same result.

And this is the fundamental property of CRDTs.


Convergence

The most crucial concept to grasp is this:

convergence_diagram

Two replicas that have received the same set of updates must always converge to the same final state, regardless of the order in which those updates arrived.

This property is known as Strong Eventual Consistency.

It is the beating heart of all CRDTs.

Note an important detail here:

CRDTs do not try to prevent conflicts.

They accept that conflicts will inevitably happen.

Their true strength lies in the fact that data merging is always deterministic.

It doesn't matter which node syncs first.

In the end, every single node will reach the same result.


But How Do We Establish the Order of Events?

We just said convergence doesn't depend on the order in which updates arrive. For our counter, that's literally true: whether you apply A's increment first or B's, the final result is still 2.

But that's the exception, not the rule.

Not all operations are so "harmless". Think about setting the value of a text field, or inserting a character at a specific position in a document: here, the order in which events are applied completely changes the final outcome.

So the moment operations stop being interchangeable, convergence needs one more ingredient: every replica has to agree on the same order of events.

Which brings up the obvious question.

If two devices modify the same data concurrently...

...who got there first?

To answer that, almost every developer instinctively reaches for the same tool — physical time:

DateTime.now()
Enter fullscreen mode Exit fullscreen mode

Whoever holds the more recent timestamp came "later", Simple.

Unfortunately, physical time is not reliable.

Think about your phone: you can manually change the date and time whenever you want. The moment you do, your device ceases to be a reliable source for ordering its own events relative to those generated by other devices.

And even without human intervention, two machines never mark the exact same instant. A device's internal clock can slightly slow down or speed up depending on how "stressed" the system is: a heavy process running on the CPU right as you generate an event is enough to skew the timestamp by a few milliseconds compared to an idle device.

So every device keeps time on its own slightly-off clock, and those clocks slowly drift apart. Sort two events by their raw timestamps and you can easily get it backwards: an event that genuinely happened first ends up looking like it came second. Worse still, two replicas ordering the same events by physical time can reach different conclusions—and once they disagree on the order, they stop converging.

This is exactly the kind of silent divergence that breaks the Strong Eventual Consistency we were chasing.

And here lies the insight that unlocks everything:

To reconcile the data, we don't actually need to know who edited first in the real world.

We just need every replica to agree on one consistent order—any order, as long as it's identical for everyone. Absolute wall-clock truth is optional; agreement is not.

So the real question becomes: once devices' clocks have drifted apart, how do we give every replica a shared, consistent order to rely on—without trusting physical time?

Over the years, several solutions have been proposed.

The first were Lamport's Logical Clocks, which establish a causal relationship between events without using real-world time at all—sacrificing the connection to human-perceivable time in the process.

More recently, Hybrid Logical Clocks (HLCs) were introduced, effectively combining the best of both worlds.

In short, the idea is this: every event is tagged with the device's physical timestamp, plus a logical counter starting at zero.

As long as physical time moves forward normally, the counter stays at zero, and the HLC acts just like a standard timestamp.

But when two events occur in the exact same physical instant, or if the local clock happens to be behind a timestamp already received from another replica, the logical counter increments. This simple mechanism guarantees a total and consistent order of events without requiring perfectly synchronized hardware clocks.

It is worth noting that HLCs are not a strict feature of CRDTs; they are simply one of the available strategies for handling time and ordering in distributed systems. Other approaches exist (like version vectors or simple logical identifiers), but the Hybrid Logical Clock remains one of the most widely discussed and appreciated solutions in modern scientific literature due to its exceptional balance between real-time accuracy and fault tolerance.
Precisely because of its robustness and the wealth of research supporting it, the HLC is the strategy I chose as the backbone for event ordering in my Dart implementation.

hlc_actor_in_crdt


Two Major Families of CRDTs

Ordering told us in what order to apply updates once they meet. But there's a second, independent question: how do those updates travel from one replica to another in the first place?

In a CRDT system, a client always executes an operation: incrementing a counter, adding a character, deleting an item.

That operation immediately updates a local state, without waiting for a server or another peer.

The challenge begins a split second later: that state needs to be replicated across the rest of the system—to devices that might be offline, experiencing slow networks, or receiving updates out of order.

How do you keep the state consistent across replicas receiving the same updates at different times and in different sequences?

The scientific literature outlines two main approaches.

op_vs_state

State-based (CvRDT)

Every replica modifies its own local state.

Periodically, it broadcasts its entire state (or a portion of it) to the other nodes.

When a replica receives new data, it performs a merge operation with its own state.

The merge algorithm is mathematically designed so that the outcome is always identical, regardless of the update arrival order.

Operation-based (CmRDT)

In this case, the state itself is not transmitted.

Only the operations are replicated.

For example:

  • Add item
  • Remove item
  • Increment counter

The other replicas apply these exact operations to arrive at the same state.

This approach significantly reduces network traffic but demands a more reliable communication infrastructure, one capable of preserving the causal relationships between events.

Ultimately, both approaches share the exact same goal:

Guaranteeing convergence.


Why "Last Writer Wins" Isn't Enough

A very common strategy in distributed databases is Last Writer Wins (LWW).

The principle is straightforward.

Two users edit the same value.

The one with the most recent timestamp wins.

It's the same DateTime.now() instinct we met earlier, promoted to a full conflict-resolution rule—except now, thanks to HLCs, the "most recent" comparison is finally trustworthy.

For something like updating a profile name, this solution is usually sufficient.

But let's imagine a collaborative text document.

I type:

APPLE
Enter fullscreen mode Exit fullscreen mode

You, simultaneously and offline, type:

BANANA
Enter fullscreen mode Exit fullscreen mode

If the document were treated as a single string, an LWW strategy would be forced to pick only one of the two versions.

The result? One user's work gets completely wiped out.

This is where Sequence CRDTs come into play.

sequence_crdt

Instead of treating the document as a single string, they model each character as an independent element equipped with a unique identifier.

During a merge, the goal isn't just to jam two texts together, but to preserve the users' intent as much as possible.

The hardest case even has a name in the literature: interleaving. What happens when two people insert text at the exact same position at the same time?

Say the document already reads Hello. Offline, I insert World at the end while you insert Dart at that very same spot. A naive merge could weave the two insertions together letter by letter, producing garbage like Hello WDoarrltd. A good Sequence CRDT instead keeps each person's insertion whole, converging on Hello World Dart (or Hello Dart World), but never a scrambled mix. Preserving those intact runs is exactly what makes these algorithms hard.

There's a second subtlety, and it shows up across CRDTs, not just sequences: deletions. The clearest way to see it is a different structure, an OR-Set (Observed-Remove Set). When you delete an item, an OR-Set doesn't actually erase it: it flags it as a tombstone, a "dead" element that still technically exists in the data structure.

Why not just delete it? Because if another replica, while offline, happened to re-add that exact same item, you want that addition to survive the merge. If you physically removed the original, you'd risk permanently losing a legitimate concurrent operation.

The trade-off is that tombstones stick around—often for a long time—causing the memory footprint to grow. This memory vs. consistency compromise is something you'll find magnified in almost every sophisticated CRDT.

Back to text specifically: in recent years several Sequence CRDT algorithms have emerged (RGA, Logoot, LSEQ, YATA, and, more recently, Fugue), each striking a different balance between memory usage, performance, and merge quality.

Diving into those details would require a dedicated article, but it's worth noting that this is currently one of the most active research areas in the CRDT space.


When Does It Make Sense to Use CRDTs?

After all this excitement, a natural question arises.

Why not just use them everywhere?

The answer is simple.

Because they aren't a universal silver bullet.

CRDTs truly shine in applications that are:

  • Local-First
  • Real-time collaborative
  • Offline-first
  • Peer-to-peer

In these scenarios, data availability is prioritized over immediate, strict consistency.

In exchange, they introduce a few trade-offs. For instance:

  • Designing the data model requires a complete shift in mindset.
  • Some CRDTs (especially text-based ones) retain extra metadata, like tombstones, which can bloat memory usage.
  • Merge algorithms are significantly more complex than those found in traditional relational databases.

As is often the case in software engineering, there is no perfect solution.

There are only tools suited for different problems.


Why I Wrote a CRDT Library for Dart

During this journey, I realized something.

In the Dart and Flutter ecosystem, there are very few modern implementations built specifically for the Local-First paradigm.

That's why I started building crdt_lf, an open-source library (also available on pub.dev) that implements various CRDTs and uses Hybrid Logical Clocks as the foundation for event ordering.

To give you a concrete idea rather than just talking theory, here is an example of two peers writing to the same text document—one offline relative to the other—and subsequently syncing up:

// Create two documents, one for each peer
final doc1 = CRDTDocument(
  peerId: PeerId.parse('45ee6b65-b393-40b7-9755-8b66dc7d0518'),
);
final doc2 = CRDTDocument(
  peerId: PeerId.parse('a90dfced-cbf0-4a49-9c64-f5b7b62fdc18'),
);

// Text handler (uses Fugue as the interleaving algorithm)
final fugueTextDoc1 = CRDTFugueTextHandler(doc1, 'text');
final fugueTextDoc2 = CRDTFugueTextHandler(doc2, 'text');

// Initial state, then synced to doc2
fugueTextDoc1.insert(0, 'Hello');
doc2.importChanges(doc1.exportChanges());

// Concurrent modifications: neither peer knows about the other's edit
fugueTextDoc1.insert(5, ' World'); // doc1: "Hello World"
fugueTextDoc2.insert(5, ' Dart'); // doc2: "Hello Dart"

// Bidirectional synchronization
final changes1 = doc1.exportChanges();
final changes2 = doc2.exportChanges();
doc2.importChanges(changes1);
doc1.importChanges(changes2);

// No edits are lost: both converge to the exact same state
print(fugueTextDoc1.value); // "Hello World Dart"
print(fugueTextDoc2.value); // identical to fugueTextDoc1.value
Enter fullscreen mode Exit fullscreen mode

Notice something: not a single line of this code explicitly handles the conflict between ' World' and ' Dart'. The merge, the HLC-based ordering, and the interleaving are entirely managed by the library under the hood.

My goal isn't just to provide a collection of data structures.

I want to build a complete ecosystem that allows developers to create collaborative apps without reinventing the entire synchronization wheel every time.

Alongside the core package, I am also working on:

  • A dedicated module for network synchronization.
  • Plugins for local persistence (such as SQLite).
  • Flutter examples that simulate slow networks, disconnections, and cross-device syncing.

The vision is to let developers focus entirely on their app's features, leaving the heavy lifting of data convergence to the library.

And theory only goes so far—so here's the whole thing running behind a real UI:

example

Two clients editing the same document side by side. One drops offline and keeps typing, the network slows to a crawl, edits pile up out of order—and the moment the connection returns, both views converge on the exact same state, with nothing lost. Everything you've read so far—convergence, HLC ordering, interleaving—is at work here, invisible to the user.


Conclusion

CRDTs are easily one of the most fascinating topics I've studied in recent years.

They blend mathematics, distributed systems, and software design in a surprisingly elegant way.

While still somewhat niche outside the distributed systems community, they are rapidly becoming a fundamental building block for an increasing number of collaborative and Local-First applications.

If this article sparked your curiosity, here is where you can explore further:

And, of course, if you like the project, a ⭐ on GitHub is always deeply appreciated.

Thanks for reading!

Top comments (0)