Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 085 / 100

CRDTs in one minute: a practical Kafka & WebSockets guide

Conflict-free replicated data types, simply.

Expona Admin7 min read

CRDTs in one minute

You're building a collaborative note-taking app and two users edit the same document offline. When they reconnect, their changes merge perfectly without conflicts or manual intervention. That's the magic of Conflict-free Replicated Data Types (CRDTs)—a deceptively simple yet powerful approach to distributed state management that I've leaned on repeatedly when building real-time systems at scale.

In this article, we'll explore what CRDTs are, why they matter for offline-first applications and event-driven architectures, and how libraries like Yjs and Automerge make them practical in production.

Every replica can change independently

The core insight of CRDTs is that each copy of your data—whether on user A's phone, user B's browser, or your backend—can be modified without coordination. No locks, no consensus protocol, no "wait for the server to approve this change." Each replica is autonomous.

Here's a concrete scenario: Imagine a shared shopping list. User A is offline and adds "milk" to the list. Simultaneously, user B (also offline) adds "eggs." When both come back online, both changes should be there. Traditional databases would require one user to overwrite the other or would force a manual merge. A CRDT handles this automatically.

The trick is in how data is structured. Instead of storing just the final value, CRDTs maintain enough metadata—like unique timestamps, user IDs, or version vectors—so that any two replicas can merge their states deterministically. No two-phase commit, no consensus voting.

Consider a simple Last-Write-Wins (LWW) register:

typescript

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

function merge<T>(local: LWWRegister<T>, remote: LWWRegister<T>): LWWRegister<T> {
  if (remote.timestamp > local.timestamp) {
    return remote;
  }
  if (local.timestamp > remote.timestamp) {
    return local;
  }
  // Tie-break by user ID
  return local.userId > remote.userId ? local : remote;
}

Each replica can independently write a new value with its own timestamp. When replicas sync, the merge function is deterministic: higher timestamp wins. Both Alice and Bob will reach the same final state, even if they never communicate directly.

Merges always converge to the same result

This is the mathematical guarantee that makes CRDTs worth your time. No matter what order updates arrive, no matter which replicas sync with which, the final state is always consistent. Formally, we call this strong eventual consistency.

Why does this matter? In a microservices world where you can't guarantee message ordering or that two services ever see the same sequence of events, CRDTs give you a safety net. Your data converges to a single truth without requiring a global authority.

Let's say you have a counter (a common CRDT type). Each replica maintains its own increment and decrement count:

typescript

type CounterCRDT = {
  increments: Map<string, number>; // userId -> count
  decrements: Map<string, number>;
};

function value(counter: CounterCRDT): number {
  let sum = 0;
  for (const inc of counter.increments.values()) sum += inc;
  for (const dec of counter.decrements.values()) sum -= dec;
  return sum;
}

function increment(counter: CounterCRDT, userId: string): CounterCRDT {
  const updated = structuredClone(counter);
  updated.increments.set(userId, (updated.increments.get(userId) ?? 0) + 1);
  return updated;
}

function merge(local: CounterCRDT, remote: CounterCRDT): CounterCRDT {
  const merged = { increments: new Map(), decrements: new Map() };
  for (const [id, count] of local.increments) {
    merged.increments.set(id, Math.max(count, remote.increments.get(id) ?? 0));
  }
  for (const [id, count] of remote.increments) {
    if (!merged.increments.has(id)) {
      merged.increments.set(id, count);
    }
  }
  // Same for decrements...
  return merged;
}

Alice increments on her replica. Bob decrements on his. They both serialize their local state and send it to a peer. The merge function is commutative (order doesn't matter), idempotent (applying it twice is the same as once), and associative (grouping doesn't matter). The result: both always see the same counter value.

Great for offline-first apps

Offline-first is a design philosophy where the app works without a network connection and syncs changes when connectivity returns. CRDTs are the technical foundation that makes this actually work.

I built a field service app for technicians where they'd be in areas with no signal. They needed to update job statuses, attach photos, and add notes—all offline. Traditional sync logic would've been a nightmare: "whose edit wins if both the field tech and the office dispatcher update the same job?" With a CRDT-based approach, both changes coexist and merge sensibly.

The UX flow looks like this:

  1. User edits data locally (instantly, no waiting for the network).
  2. Changes are recorded in a CRDT structure.
  3. When online, replicas sync by exchanging their state deltas.
  4. Both sides merge using the CRDT merge function.
  5. All instances converge to the same state automatically.

This also plays well with event streaming. You can publish CRDT mutations as events to Kafka. Other services consume those events, apply them to their local CRDT replicas, and end up consistent. No orchestration layer needed.

For a collaborative document editor, consider how Yjs (mentioned below) handles this. Each keystroke becomes a tiny event. Offline, these events queue locally. When you sync, you send only the events since the last sync point. Peers merge them using Yjs's internal CRDT algorithm. No server "wins," no conflict dialogs.

Libraries: Yjs, Automerge

Yjs is a high-performance CRDT library optimized for real-time collaboration. It's battle-tested in production and integrates with Monaco, CodeMirror, TipTap, and other editors.

typescript

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

const ydoc = new Y.Doc();
const ytext = ydoc.getText('shared-text');

// Listen for changes
ytext.observe((event) => {
  console.log('Text changed:', ytext.toString());
});

// Make changes
ytext.insert(0, 'Hello ');

// Sync with server
const provider = new WebsocketProvider(
  'ws://localhost:1234',
  'shared-room',
  ydoc
);

Yjs excels at:

  • High throughput (thousands of concurrent updates).
  • Minimal bandwidth for syncing (only deltas).
  • Bindings to popular UI frameworks (Svelte, Vue, React).
  • Built-in Awareness for cursor positions and presence.

Automerge takes a different approach: it's JSON-like and treats your entire state as a CRDT. Each change is tracked, and you can traverse the history or create branches.

typescript

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

let doc1 = Automerge.from({ tasks: [] });
let doc2 = Automerge.clone(doc1);

doc1 = Automerge.change(doc1, (d) => {
  d.tasks.push({ title: 'Task 1', done: false });
});

doc2 = Automerge.change(doc2, (d) => {
  d.tasks.push({ title: 'Task 2', done: false });
});

// Merge both versions
const merged = Automerge.merge(doc1, doc2);
console.log(merged.tasks); // Both tasks are present

Automerge is ideal for:

  • Complex nested structures (JSON-like data).
  • Rich undo/redo and branching workflows.
  • Academic and research use cases where correctness trumps performance.
  • Building time-travel debugging into your app.

For most production real-time collaboration, Yjs is faster and leaner. For complex stateful apps where you need full history tracking, Automerge shines.

Common mistakes

Mistake 1: Assuming CRDTs solve all consistency problems. CRDTs guarantee eventual consistency, not strong consistency. If you need strict ordering or serializable reads, you still need coordination. CRDTs are for scenarios where "eventually everyone sees the same value" is acceptable.

Mistake 2: Using naive LWW for domain logic. Last-Write-Wins can silently drop concurrent changes. If user A sets their name to "Alice" and user B sets a flag to true at the same time, LWW on the name field might drop one update. Use semantically richer CRDTs (like Yjs's content types) that preserve both changes.

Mistake 3: Ignoring metadata overhead. CRDTs add metadata to track uniqueness and causality. A Counter CRDT stores increments per user. A Text CRDT stores position IDs. On large datasets or with many concurrent users, this metadata grows. Monitor it in production.

Mistake 4: Not planning for garbage collection. Yjs and Automerge can prune old state that's no longer needed, but you must explicitly encode when that's safe. If you don't, your data structure grows unbounded.

Mistake 5: Mixing CRDTs with mutable external state. If your CRDT represents a shopping list but the actual inventory is in a database, you need a separate reconciliation layer. A CRDT alone can't resolve "user ordered 10 apples, but only 3 are in stock." Treat CRDTs for transient collaboration state, not authoritative system state.

Key takeaways

  • CRDTs allow independent replicas to diverge and later merge deterministically without coordination.
  • Merges always converge to the same state due to mathematical properties: commutativity, idempotence, and associativity.
  • Perfect for offline-first apps and event-driven systems where strong consistency is overkill.
  • Yjs is the practical choice for real-time text collaboration; Automerge excels at complex nested state and historical workflows.
  • CRDTs guarantee eventual consistency, not strong consistency. Combine them with backend coordination when strict ordering matters.
  • Watch out for naive LWW implementations, unbounded metadata growth, and mixing CRDTs with authoritative data sources.
  • CRDTs + WebSockets + Kafka form a powerful trio for building scalable, resilient collaborative systems.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles