Conflict Resolution Strategies for Offline-First Apps: CRDTs in Practice

How to design mobile apps that work seamlessly without a network connection, with sync strategies and a practical code example.

The Hard Problem Offline-First Apps Eventually Hit

Once two devices can independently edit the same data while offline, you need a strategy for reconciling conflicting changes when they sync back up. Simple approaches break down fast; this is where Conflict-free Replicated Data Types (CRDTs) earn their complexity.

Why “Last Write Wins” Isn’t Enough

Last-write-wins silently discards one user’s changes, which is fine for a UI preference but genuinely unacceptable for something like a shared shopping list or collaborative document — a user’s real work just disappears without any indication it happened.

A Simple CRDT: Grow-Only Counter

class GCounter {
  constructor(nodeId) {
    this.nodeId = nodeId;
    this.counts = {};
  }

  increment() {
    this.counts[this.nodeId] = (this.counts[this.nodeId] || 0) + 1;
  }

  value() {
    return Object.values(this.counts).reduce((a, b) => a + b, 0);
  }

  merge(other) {
    for (const [node, count] of Object.entries(other.counts)) {
      this.counts[node] = Math.max(this.counts[node] || 0, count);
    }
  }
}

Two devices can independently increment the same counter offline, and merging simply takes the max per-node count — no conflicts possible by construction.

A Practical CRDT: Last-Write-Wins Register with Timestamps

class LWWRegister {
  constructor(value = null, timestamp = 0) {
    this.value = value;
    this.timestamp = timestamp;
  }

  set(value, timestamp) {
    if (timestamp > this.timestamp) {
      this.value = value;
      this.timestamp = timestamp;
    }
  }

  merge(other) {
    if (other.timestamp > this.timestamp) {
      this.value = other.value;
      this.timestamp = other.timestamp;
    }
  }
}

Using a CRDT Library Instead of Rolling Your Own

For anything beyond simple counters, use a battle-tested library like Yjs or Automerge rather than implementing CRDTs from scratch:

import * as Y from 'yjs';

const doc = new Y.Doc();
const list = doc.getArray('shoppingList');

list.push(['milk', 'eggs']);

// Sync state between devices
const state = Y.encodeStateAsUpdate(doc);
Y.applyUpdate(remoteDoc, state);

When CRDTs Are Overkill

Not every offline-first feature needs CRDTs. For data owned by a single user that’s never edited on two devices simultaneously in practice (personal notes, settings), a simple versioned last-write-wins with a manual conflict prompt is often sufficient and far simpler to reason about.

Handling Merge Conflicts the User Should See

function detectConflict(localVersion, remoteVersion, baseVersion) {
  const localChanged = localVersion !== baseVersion;
  const remoteChanged = remoteVersion !== baseVersion;
  return localChanged && remoteChanged && localVersion !== remoteVersion;
}

When true conflicts exist and automatic resolution isn’t safe (financial data, for example), surface both versions to the user rather than silently picking one.

Conclusion

CRDTs solve the specific problem of merging concurrent offline edits without data loss or manual conflict resolution — but reach for an established library rather than implementing the merge semantics yourself, since subtle bugs in custom CRDT logic are hard to catch in testing and expensive to fix after real user data is affected.