<!-- Canonical: https://lucioduran.com/blog/crdts-local-first-software-architecture
Language: en
Content-SHA256: 2d1b1bb8d8bf8c6059a94af24d907429603410204bac3a0f0ee76ef86eaa1529
-->

Distributed Systems

# CRDTs for Local-First Software: Building Collaborative Apps Without Servers

2025-12-1610 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/crdts-local-first-software-architecture>)

## CRDTs from first principles

Before examining [Yjs](<https://yjs.dev/>) and [Automerge](<https://automerge.org/>), it is worth understanding the underlying mechanics, since most CRDT tutorials skip the theory and the merging behavior becomes difficult to reason about.

A CRDT guarantees that if two replicas have received the same set of operations (in any order), they'll converge to the same state. There are two families:

**State-based CRDTs (CvRDTs)**: Replicas send their full state to each other and merge using a function that's commutative, associative, and idempotent. Think of a G-Counter:

```javascript
// G-Counter: each node maintains its own counter
// State-based, you merge by taking the max of each node's count
class GCounter {
 constructor(nodeId) {
 this.nodeId = nodeId;
 this.counts = {}; // { nodeId: count }
 }

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

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

 merge(other) {
 // Take the max for each node, this is the magic
 // Commutative: merge(A,B) = merge(B,A)
 // Associative: merge(merge(A,B),C) = merge(A,merge(B,C))
 // Idempotent: merge(A,A) = A
 const merged = new GCounter(this.nodeId);
 const allNodes = new Set([
 ...Object.keys(this.counts),
 ...Object.keys(other.counts)
 ]);
 for (const node of allNodes) {
 merged.counts[node] = Math.max(
 this.counts[node] || 0,
 other.counts[node] || 0
 );
 }
 return merged;
 }
}
```

**Operation-based CRDTs (CmRDTs)**: Replicas send operations to each other. Operations must be commutative (can be applied in any order). This is what Yjs and Automerge actually use, because sending "insert character 'a' at position 3" is way cheaper than sending the entire document.

The tricky part with op-based CRDTs for sequences (like text) is defining what "position 3" means when two users are typing simultaneously. This is where things get really interesting.

## Yjs: the performance king

Yjs uses a modified YATA (Yet Another Transformation Approach) algorithm for its sequence types. Every character gets a unique ID based on the client ID and a logical clock:

```javascript
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';

// Create a Yjs document
const ydoc = new Y.Doc();

// Shared types, these are the CRDTs
const ytext = ydoc.getText('editor');
const ymap = ydoc.getMap('metadata');
const yarray = ydoc.getArray('comments');

// Connect to other peers
const provider = new WebsocketProvider(
 'wss://sync.example.com',
 'document-abc-123',
 ydoc
);

// Observe changes from any source (local or remote)
ytext.observe((event) => {
 event.delta.forEach((op) => {
 if (op.insert) console.log('Inserted:', op.insert);
 if (op.delete) console.log('Deleted:', op.delete, 'chars');
 if (op.retain) console.log('Retained:', op.retain, 'chars');
 });
});

// Make changes inside a transaction
ydoc.transact(() => {
 ytext.insert(0, 'Hello, ');
 ytext.insert(7, 'world!');
 ymap.set('lastEditor', 'lucio');
 ymap.set('updatedAt', Date.now());
});
```

What makes Yjs fast is its internal representation. Instead of storing each character as a separate node in a linked list (which is what the YATA paper describes), Yjs uses a technique called "struct optimization", consecutive characters from the same user are stored as a single struct:

```
// Instead of:
[{id: {client:1, clock:0}, content: 'H'},
 {id: {client:1, clock:1}, content: 'e'},
 {id: {client:1, clock:2}, content: 'l'},
 {id: {client:1, clock:3}, content: 'l'},
 {id: {client:1, clock:4}, content: 'o'}]

// Yjs stores:
[{id: {client:1, clock:0}, content: 'Hello', length: 5}]
```

This single optimization makes Yjs orders of magnitude faster than naive CRDT implementations. Kevin Jahns (Yjs's author) published benchmarks showing Yjs processing 260,000 edits in 0.5 seconds. I've verified similar numbers in my own testing.

### Building a real collaborative editor with Yjs

Here's a more complete example showing how to wire Yjs into a production editor:

```javascript
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
import { Awareness } from 'y-protocols/awareness';

const ydoc = new Y.Doc();

// Layer 1: Local persistence, works offline
const localProvider = new IndexeddbPersistence('doc-abc-123', ydoc);
localProvider.on('synced', () => {
 console.log('Loaded from local IndexedDB');
});

// Layer 2: Network sync, when available
const wsProvider = new WebsocketProvider(
 'wss://sync.example.com',
 'doc-abc-123',
 ydoc,
 { connect: navigator.onLine }
);

// Handle online/offline
window.addEventListener('online', () => wsProvider.connect());
window.addEventListener('offline', () => wsProvider.disconnect());

// Layer 3: Awareness, ephemeral presence data
const awareness = wsProvider.awareness;
awareness.setLocalStateField('user', {
 name: 'Lucio',
 color: '#d97757',
 cursor: null,
});

// Listen for other users' presence
awareness.on('change', () => {
 const states = awareness.getStates();
 states.forEach((state, clientId) => {
 if (clientId !== ydoc.clientID) {
 renderRemoteCursor(state.user);
 }
 });
});

// Undo/Redo with proper CRDT semantics
const undoManager = new Y.UndoManager(ydoc.getText('editor'), {
 trackedOrigins: new Set([null]), // Only track local changes
 captureTimeout: 500, // Group rapid edits
});
```

The three-layer architecture (IndexedDB + WebSocket + Awareness) is the pattern I've settled on after trying a dozen different approaches. IndexedDB gives you instant load and offline support. WebSocket gives you real-time sync. Awareness gives you presence without polluting the document history.

## Automerge 2.0: the structured data champion

While Yjs dominates in text editing, Automerge 2.0 brings a different philosophy. It treats the document as a JSON-like tree with full change history, every modification is tracked, and you can "time travel" through the document's evolution.

```javascript
import * as Automerge from '@automerge/automerge';

// Create a document
let doc = Automerge.init();

// Make changes
doc = Automerge.change(doc, 'Add initial task', (d) => {
 d.tasks = [];
 d.tasks.push({
 id: 'task-1',
 title: 'Implement CRDT sync',
 status: 'in_progress',
 assignee: 'lucio',
 subtasks: [
 { title: 'Research Yjs vs Automerge', done: true },
 { title: 'Build prototype', done: false },
 ],
 });
});

// Simulate concurrent edits from two peers
let doc1 = Automerge.clone(doc);
let doc2 = Automerge.clone(doc);

// Peer 1: mark task as done
doc1 = Automerge.change(doc1, 'Complete task', (d) => {
 d.tasks[0].status = 'done';
});

// Peer 2: add a subtask (concurrently)
doc2 = Automerge.change(doc2, 'Add subtask', (d) => {
 d.tasks[0].subtasks.push({
 title: 'Write blog post about CRDTs',
 done: false,
 });
});

// Merge, both changes are preserved, no conflicts
let merged = Automerge.merge(doc1, doc2);
console.log(merged.tasks[0].status); // 'done'
console.log(merged.tasks[0].subtasks.length); // 3
```

Automerge 2.0's killer feature is its binary format. The internal storage uses a columnar encoding that's incredibly compact:

```javascript
// Save to binary format
const binary = Automerge.save(doc);
console.log(binary.length); // ~200 bytes for this document

// vs JSON
const json = JSON.stringify(Automerge.toJS(doc));
console.log(json.length); // ~350 bytes

// The difference gets dramatic with history
// A document with 10,000 changes:
// Binary: ~50KB (stores full history)
// JSON snapshot: ~5KB (no history)
// JSON with history: ~2MB+
```

### Automerge sync protocol

Automerge has a built-in sync protocol that's remarkably elegant. Each peer tracks which changes it has and which changes it thinks the other peer has:

```javascript
import * as Automerge from '@automerge/automerge';

class SyncConnection {
 constructor(doc, sendFn) {
 this.doc = doc;
 this.send = sendFn;
 this.syncState = Automerge.initSyncState();
 }

 // Generate a sync message to send to the peer
 generateSyncMessage() {
 const [newSyncState, message] = Automerge.generateSyncMessage(
 this.doc,
 this.syncState
 );
 this.syncState = newSyncState;
 if (message) {
 this.send(message);
 }
 }

 // Receive a sync message from the peer
 receiveSyncMessage(message) {
 const [newDoc, newSyncState] = Automerge.receiveSyncMessage(
 this.doc,
 this.syncState,
 message
 );
 this.doc = newDoc;
 this.syncState = newSyncState;
 // After receiving, generate a response
 this.generateSyncMessage();
 }
}
```

The sync protocol figures out the minimal set of changes to exchange. If two peers are mostly in sync, the messages are tiny. If one peer has been offline for a week, the first message will be larger but still only contains the delta.

## The conflict resolution reality

Here's something that took me a while to internalize: CRDTs don't eliminate conflicts, they make conflict resolution deterministic and automatic. But "automatic" doesn't always mean "correct from a user's perspective."

Consider two users editing the same list item simultaneously:

```javascript
// User A: rename task
doc_A = Automerge.change(doc_A, (d) => {
 d.tasks[0].title = 'Build CRDT sync engine';
});

// User B: also rename the same task
doc_B = Automerge.change(doc_B, (d) => {
 d.tasks[0].title = 'Implement real-time sync';
});

// After merge: one wins based on actor ID ordering
// The "losing" edit is not lost, it's in the history
let merged = Automerge.merge(doc_A, doc_B);
// merged.tasks[0].title is deterministically one of the two values
```

For scalar values (strings, numbers), CRDTs use "last writer wins" with a deterministic tiebreaker. This is usually fine, but sometimes you need application-level conflict handling:

```javascript
// Detect conflicts in Automerge
const conflicts = Automerge.getConflicts(merged.tasks[0], 'title');
if (conflicts) {
 // Show UI to let user choose
 console.log('Conflicting values:', Object.values(conflicts));
}
```

In Yjs, you handle this differently, the Y.Map type doesn't expose conflicts for primitive values (LWW wins silently), but Y.Text uses character-level interleaving that usually produces reasonable results for concurrent edits.

My approach: use CRDTs for the sync layer but implement domain-specific merge strategies on top. For a kanban board, concurrent moves to different columns should probably result in the most recent move winning. For a collaborative document, concurrent edits to the same paragraph should be interleaved. For a shopping list, concurrent additions should both appear.

## Architecture patterns for local-first apps

After building several local-first applications, I've converged on this architecture:

```
┌─────────────────────────────────────────────┐
│ Client │
│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ UI Layer │ │ CRDT Document │ │
│ │ (React) │◄─┤ (Yjs / Automerge) │ │
│ └─────────────┘ └──────┬───────────────┘ │
│ │ │
│ ┌───────────────────────┼────────────────┐ │
│ │ Persistence Layer │ │ │
│ │ ┌─────────┐ ┌──────┴──────┐ │ │
│ │ │ IndexedDB│ │ Sync Engine │ │ │
│ │ │ (offline)│ │ (WebSocket) │ │ │
│ │ └─────────┘ └─────────────┘ │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
 │
 ▼
┌─────────────────────────────────────────────┐
│ Sync Server (optional, stateless) │
│ - Relays updates between peers │
│ - Stores snapshots for new peers │
│ - Auth/access control │
│ - Does NOT resolve conflicts │
└─────────────────────────────────────────────┘
```

The sync server is deliberately thin. It doesn't understand the document format, it just relays binary messages between peers and stores the latest snapshot for bootstrapping new clients. This means you can use [Cloudflare](<https://cloudflare.com/>) Workers, a simple WebSocket server, or even a peer-to-peer protocol like WebRTC.

```javascript
// Minimal sync server with y-websocket
const WebSocket = require('ws');
const { setupWSConnection } = require('y-websocket/bin/utils');

const wss = new WebSocket.Server({ port: 1234 });

wss.on('connection', (ws, req) => {
 const docName = req.url.slice(1); // /document-id -> document-id

 // y-websocket handles all the sync protocol details
 setupWSConnection(ws, req, {
 docName,
 gc: true, // Enable garbage collection
 });
});
```

That's the entire server. Fifteen lines. The complexity lives in the CRDT library, which runs on the client.

## Performance reality check

Let me be honest about the trade-offs. CRDTs are not free:

**Memory**: A Yjs document with 100,000 characters of text and full history uses ~2MB of memory. The equivalent plain string is 100KB. That 20x overhead is the cost of tracking every operation for conflict resolution.

**Document size**: Automerge documents grow with history. A document that's been heavily edited over months can reach 10-50MB in binary format. You need compaction strategies:

```javascript
// Automerge compaction, squash history
const compacted = Automerge.clone(doc); // Cloning discards detailed history
// Or save with specific heads to trim
```

**Initial sync**: When a new peer joins, it needs the full document. For large documents, this can take seconds. Use snapshot + incremental updates:

```javascript
// Server stores periodic snapshots
const snapshot = Automerge.save(doc);
// New peer loads snapshot, then applies incremental updates
let peerDoc = Automerge.load(snapshot);
for (const update of incrementalUpdates) {
 [peerDoc] = Automerge.receiveSyncMessage(peerDoc, syncState, update);
}
```

Despite these trade-offs, for applications where real-time collaboration and offline support matter, CRDTs are the only approach that doesn't require you to build a custom conflict resolution system from scratch. The CRDT library authors have thought about edge cases you haven't even imagined yet.

The local-first movement is still early, but the tools are finally production-ready. Pick Yjs for real-time collaborative editing, Automerge for structured data with rich history, and build your sync layer to be as thin as possible. The less your server knows about your data, the more resilient your architecture becomes.

## Frequently asked questions

### What is a CRDT and why should I care?

A Conflict-free Replicated Data Type is a data structure that can be modified independently on multiple devices and always merged into a consistent state without conflicts. It enables real-time collaboration and offline-first apps without complex server-side conflict resolution.

### Yjs or Automerge, which should I pick?

Yjs for collaborative text editing and real-time features where performance matters most. Automerge 2.0 for structured document data where you want a richer change history and time-travel capabilities. Both are production-ready.

### Can CRDTs replace a traditional database?

Not entirely. CRDTs handle conflict resolution for concurrent edits brilliantly, but they don't provide queries, indexing, access control, or server-side validation. Use CRDTs for the collaboration layer and a traditional database for the system of record.

## Tips

1. **Tip:** Use Yjs for text-heavy collaborative editing, its ` Y.Text ` type uses a YATA-based CRDT that handles concurrent inserts better than Automerge's sequence type.
2. **Tip:** Automerge 2.0's binary format is ~10x smaller than JSON for document storage. Use ` Automerge.save() ` for persistence, not ` JSON.stringify() `.
3. **Tip:** Always implement ` gc: true ` in Yjs providers for long-lived documents, without it, the internal deletion tombstones grow unbounded and will eat your memory.
4. **Tip:** For presence features (cursors, selections), don't use the CRDT document, use an ephemeral awareness protocol like ` y-protocols/awareness ` instead.

- crdts
- local-first
- yjs
- automerge
- distributed-systems
- collaboration
- offline-first
- conflict-resolution
- real-time

[Back to Blog](<https://lucioduran.com/blog>)
