System Design

CRDTs: Conflict-Free Merging in Distributed Systems

CRDTs let distributed nodes accept writes and merge without conflict or coordination. No locking, no round-trips. Here is when the trade-off makes sense.

11 May 2026

CRDTs: Conflict-Free Merging in Distributed Systems

Imagine two people editing the same Google Doc while offline. When both go back online, Google has to figure out how to combine their changes. It cannot just pick one person's version and throw away the other. It needs to merge them.

The naive solution is a central server that everyone checks with before writing. But that means every edit has to wait for a network round-trip. Slow. And if the server is unreachable, you cannot edit at all.

CRDTs (Conflict-free Replicated Data Types) solve this differently. They are data structures designed so that two copies can always be merged without conflict, no matter what order changes happened in. No central server needed. No locking. No "who goes first."

This is how Figma lets two designers move the same element simultaneously. Both get instant feedback locally. When their changes sync, the canvas converges to the same state. No one's work is lost.

Why merging is hard in the first place

Think about a simple counter. Two servers both start at count = 5. Server A increments it. Server B also increments it. Now Server A has 6 and Server B has 6. When they sync, what should the counter be?

The answer should be 7 (two increments happened), but if you just take the latest value you get 6. You lost an update.

This is the core problem CRDTs solve: how do you design data structures so that merging two copies always produces the correct result?

The three rules a merge function must follow

Every CRDT works by defining a merge function that follows three rules. You do not need to memorize the formal names, but understanding what they mean is useful:

  1. Order does not matter: merging A into B gives the same result as merging B into A. If you receive updates out of order (which happens constantly in distributed systems), you still end up in the same place.

  2. Grouping does not matter: merging A and B first, then merging in C, gives the same result as merging B and C first, then merging in A. This matters when different nodes sync at different times.

  3. Duplicates are safe: merging the same update twice does not change the result. Networks sometimes deliver messages more than once. This property means that is fine.

If your merge function follows all three rules, nodes can receive updates in any order, miss some updates and catch up later, and still all converge to the same state. That is the guarantee CRDTs give you.

G-Counter: the simplest CRDT

The counter problem above has a clean solution. Instead of one shared number, each server keeps its own slot in a list.

Server 1 can only increment slot 1. Server 2 can only increment slot 2. The real counter value is the sum of all slots.

Typescript
type GCounter = Record<string, number> // serverId -> how many times it incremented

function increment(counter: GCounter, serverId: string): GCounter {
  return { ...counter, [serverId]: (counter[serverId] ?? 0) + 1 }
}

function merge(a: GCounter, b: GCounter): GCounter {
  const result: GCounter = { ...a }
  for (const serverId of Object.keys(b)) {
    result[serverId] = Math.max(result[serverId] ?? 0, b[serverId])
  }
  return result
}

function value(counter: GCounter): number {
  return Object.values(counter).reduce((sum, n) => sum + n, 0)
}

When merging, you take the maximum of each slot. If server A says it incremented 5 times and server B says 3 times, the merged result is {A: 5, B: 3} and the total is 8.

This works because each server is the only one that can modify its own slot. There is nothing to conflict over. The G stands for Grow-only: the counter only ever goes up.

What about decrement? You add a second G-Counter that tracks decrements, then subtract. This pattern (add a parallel structure for the operation you need) comes up a lot in CRDT design.

G-Set and 2P-Set: sets that can merge

A G-Set is the simplest set: you can only add to it, never remove. Merging two G-Sets is just a union. Since you never remove anything, there is nothing to conflict about.

Typescript
type GSet<T> = Set<T>

function merge<T>(a: GSet<T>, b: GSet<T>): GSet<T> {
  return new Set([...a, ...b])
}

The problem: real apps need removal.

The 2P-Set adds a second "removed" set, called a tombstone set. To remove an element, you add it to the tombstone set. When looking up an element, you check: is it in the added set AND not in the tombstone set?

The catch: once you remove something, it is gone permanently. You cannot add it back. That is a real limitation, but it is fine for use cases like "users who have left a room" where re-adding makes no sense.

For cases where you need to add, remove, and re-add the same item, there is the OR-Set (Observed-Remove Set). Each time you add an element, it gets a unique tag. Removing only cancels the specific tags that existed at that moment. Re-adding creates a new tag, so the element survives the merge.

Typescript
type ORSet<T> = {
  added: Map<T, Set<string>>  // element -> set of unique tags
  removed: Set<string>         // tags that have been removed
}

function add<T>(set: ORSet<T>, element: T, tag: string): ORSet<T> {
  const existing = set.added.get(element) ?? new Set()
  return {
    ...set,
    added: new Map(set.added).set(element, new Set([...existing, tag])),
  }
}

function remove<T>(set: ORSet<T>, element: T): ORSet<T> {
  const tags = set.added.get(element) ?? new Set()
  return {
    ...set,
    removed: new Set([...set.removed, ...tags]),
  }
}

function has<T>(set: ORSet<T>, element: T): boolean {
  const tags = set.added.get(element) ?? new Set()
  return [...tags].some(tag => !set.removed.has(tag))
}

If two nodes concurrently add and remove the same element, the add wins, because the new tag is not in the removed set. This is a deliberate design decision, not an accident. Different applications want different behavior here; CRDTs let you encode that choice explicitly in the data structure.

LWW-Register: last write wins (carefully)

Sometimes the right merge strategy really is "take the latest value." A LWW-Register (Last-Write-Wins Register) stores a value alongside a timestamp, and merge always picks the higher timestamp.

Typescript
type LWWRegister<T> = { value: T; timestamp: number }

function write<T>(value: T): LWWRegister<T> {
  return { value, timestamp: Date.now() }
}

function merge<T>(a: LWWRegister<T>, b: LWWRegister<T>): LWWRegister<T> {
  return a.timestamp >= b.timestamp ? a : b
}

This is simple, but there is a real trap: clocks on different servers are not perfectly in sync. One server might be a few milliseconds behind another. NTP can even cause a clock to jump backwards. If two writes happen at almost the same moment, the "winner" depends on clock accuracy, not actual order.

For low-stakes data (like "last seen" status), this is usually fine. For anything where losing a write would be a problem, you need a better clock. The standard fix is a hybrid logical clock that combines wall time with a counter, so you get causal ordering even when wall clocks disagree. Cassandra uses this approach for its LWW columns.

How collaborative text editing works

Handling concurrent text edits is harder than counters or sets. If two users both type at the same cursor position, you cannot just merge by position because the positions shift as characters are inserted.

The solution is to stop tracking positions and start tracking relationships. Each character gets a unique ID (based on who inserted it and when). Instead of "insert at position 5," you record "insert after character with ID X."

When two users insert at the same place simultaneously, a tie-breaking rule (usually comparing the inserting user's ID) determines the final order. Neither edit is lost. The result might not be what a human editor would choose, but it is stable and consistent across all nodes.

This is the approach used by Yjs and [Automerge], two of the most popular CRDT libraries. They power the offline and collaborative features in many modern editors.

When to use CRDTs

CRDTs are the right choice when:

  • Users need to work offline and sync later (notes apps, collaborative editors, field tools)
  • You have multiple regions and cannot afford a round-trip to a central server on every write
  • You want to let different parts of your system accept writes independently without coordination
  • The data naturally maps to a grow-only or set structure (presence, counters, tags)

CRDTs are not the right choice when you need strict consistency. A bank account balance that must never go negative cannot be managed with a PN-Counter alone. CRDTs guarantee that all nodes will eventually agree on the same value. They do not guarantee that the agreed-upon value respects every constraint you care about. For that, you still need coordination.

There is also a storage cost. Tombstones in sets accumulate over time. A set that has had a million removals holds a million tombstones. Production systems need a way to garbage collect old tombstones, which usually requires a coordination step once all nodes have acknowledged them. Not hard, but it is extra work you need to plan for.

What this changes about how you build systems

Once you understand CRDTs, you start to see that not all of your data needs the same strategy. Some fields naturally fit a CRDT: a like count, a set of tags, a presence indicator. Other fields need strict coordination: a bank balance, a seat reservation, a unique username claim.

The best architectures use CRDTs where they fit and coordination only where it is actually required. This lets you scale write capacity to the edge and handle offline scenarios without giving up correctness on the data that needs it.

This is the model behind Riak's built-in data types, Redis cluster's CRDT features, and most of the collaborative editing tooling built in the last decade.

CRDTs are also the enabling technology behind local-first architecture, where apps work fully offline and sync when connectivity returns. If you want to see a related pattern from a different angle, event sourcing uses an append-only log to preserve the full history of changes rather than collapsing them into a single mutable value.


If you want to dig deeper into distributed systems trade-offs like this, I cover them every week in the Monday BY Gazar newsletter. If you want to work through a specific system design problem together, book a free intro call.