Two Devices, No Server, No Conflicts: How Local-First Apps Merge

✓ numbers produced by the code on this page · Published 2026-08-20

Software is quietly moving back toward running on your device. Apps that work on a plane, sync when they can, and never show a spinner because a server is deciding something — local-first is the usual name — and the hard part is not storage. It is what happens when two devices changed the same thing while neither could see the other.

The traditional answers are both bad. Refuse to merge and you get a conflict dialog nobody understands. Take the last write and you silently throw away somebody’s work.

Conflict-free replicated data types are the third answer, and the surprising thing about them is not the algebra. It is that the merged result is usually a list neither device was holding.

Predict the merge

Both devices started with the same list, then went offline and edited independently.

Merge two offline edits the answer is neither device's list

Local-first software keeps working with no network and reconciles afterwards, with no server deciding who was right. The structures that make that possible are conflict-free replicated data types, and the surprising part is not the algebra — it is that the merged result is usually a list neither device was holding.

Both devices start with eggs, milk. Offline, Alice removes milk and adds bread, leaving bread, eggs. At the same time Bob removes eggs and re-adds milk, leaving milk. Sync them and the answer is bread, milk.

Milk survives Alice's removal because Bob's addition of it was one she had never seen — a remove deletes only the additions it has actually observed. No clock is consulted anywhere. Last-writer-wins would instead have kept one device's whole list and discarded the other's, losing real work silently. And the merge is order-independent (true), unchanged by repeating (true), and unchanged by merging with itself (true) — which is what lets devices sync in any order, any number of times, over any unreliable connection.

Predict before you sync. Most people expect one device's list to win — the answer is a combination that was never on screen anywhere.

Why milk comes back

python
class OrSet:
    """Observed-remove set. Each add makes a UNIQUE tag; a remove deletes only the tags this
    replica has actually seen. That is the whole mechanism, and it is why concurrent
    add-and-remove resolves to add."""

    def __init__(self, replica):
        self.replica = replica
        self.adds = set()          # (element, tag)
        self.removes = set()       # tags
        self.counter = 0

    def add(self, element):
        self.counter += 1
        self.adds.add((element, f"{self.replica}{self.counter}"))

    def remove(self, element):
        for el, tag in self.adds:
            if el == element:
                self.removes.add(tag)   # only tags visible HERE

    def value(self):
        return sorted({el for el, tag in self.adds if tag not in self.removes})

    def merge(self, other):
        merged = OrSet(self.replica + other.replica)
        merged.adds = self.adds | other.adds
        merged.removes = self.removes | other.removes
        return merged

    def copy_as(self, replica):
        clone = OrSet(replica)
        clone.adds = set(self.adds)
        clone.removes = set(self.removes)
        return clone


# Both devices start from the same synced state.
origin = OrSet("s")
origin.add("eggs")
origin.add("milk")
print("both start with:", origin.value())

alice = origin.copy_as("a")
bob = origin.copy_as("b")

# Offline, at the same time, neither seeing the other.
alice.remove("milk")
alice.add("bread")
bob.remove("eggs")
bob.add("milk")          # a NEW add, with a new tag

print("alice offline has:", alice.value())
print("bob offline has:  ", bob.value())

merged = alice.merge(bob)
print("after syncing:    ", merged.value())
print()

# The naive alternative: whole-document last-writer-wins.
print("last-writer-wins would keep one device's list and discard the other:")
print("  if alice wrote last:", alice.value())
print("  if bob wrote last:  ", bob.value())
print()

# Order of merging must not matter. That is the property the whole family is named for.
print("merge is order-independent:", alice.merge(bob).value() == bob.merge(alice).value())
print("merging twice changes nothing:", merged.merge(bob).value() == merged.value())
print("merging with itself changes nothing:", merged.merge(merged).value() == merged.value())
Output
both start with: ['eggs', 'milk']
alice offline has: ['bread', 'eggs']
bob offline has:   ['milk']
after syncing:     ['bread', 'milk']

last-writer-wins would keep one device's list and discard the other:
  if alice wrote last: ['bread', 'eggs']
  if bob wrote last:   ['milk']

merge is order-independent: True
merging twice changes nothing: True
merging with itself changes nothing: True

Neither device was holding bread, milk. It is nevertheless the right answer, and it is the union of what the two people actually intended: Alice wanted bread, Bob wanted milk, and eggs was removed by someone and re-added by no one.

Milk survives Alice’s removal because her removal never saw Bob’s addition. Every add creates a unique tag. A remove deletes the tags that replica can currently see — Alice deleted the tag from the original shared state. Bob’s re-add produced a different tag, one Alice’s remove could not have touched. After the merge that tag is still live, so milk is in the set.

That is causality doing the work, not time. Nowhere in the merge does anything ask which edit happened later, and there is no clock to disagree about — which matters, because clocks on separate devices genuinely do disagree, and a merge that depends on them is a merge that depends on a lie.

Why the three laws at the bottom matter

They look like formality and they are the entire operational argument.

Together these mean sync needs no coordination at all. No leader, no locks, no ordering guarantee from the network — only that every update eventually reaches every device, in any order, possibly more than once. That is a much weaker requirement than a database transaction, and it is why this approach works over flaky mobile connections and peer-to-peer links where consensus protocols struggle.

What it costs

The tags. Every addition stores a unique tag forever, and removals store the tags they deleted, so the metadata grows with the history rather than with the current contents. A list of ten items edited for a year is far bigger than a list of ten items. Real implementations spend considerable effort on compaction, and it is the main practical objection to CRDTs.

There is also no such thing as the correct merge in general — only a defined one. This set is add-wins: a concurrent add and remove keeps the item. A remove-wins variant is equally consistent and drops it. Neither is more correct; they encode different product decisions, and choosing between them is a design question rather than a technical one.

What this page simplifies