Collaborative editing basics
Imagine you and a teammate opening the same Google Doc, both typing at the same time, and watching your changes merge seamlessly. That magic is collaborative editing basics—the challenge of letting multiple people edit the same thing at once without losing work or creating chaos. In this article, I'll walk you through how real systems handle simultaneous edits, from conflict resolution strategies to production-ready libraries.
The conflict problem — Two users change the same text simultaneously
The core issue is deceptively simple: what happens when Alice and Bob both edit the same document at the exact same moment?
Let's say we have a shared string: "Hello World". At time T, Alice deletes "World" and replaces it with "Kafka". Simultaneously, Bob inserts "beautiful " before "World". Without coordination, one of these edits will overwrite the other. Here's what naive approaches do wrong:
- Last-write-wins (LWW): Whichever edit arrives at the server last wins. Bob's change gets lost entirely, and Alice never knows it happened.
- Lock-based editing: Only one user can edit at a time. Everyone else waits. Terrible UX.
- Reload on conflict: Detect a conflict, tell the user to refresh and redo their work. This is what many homegrown systems do.
Real collaborative systems need to merge conflicting edits intelligently. Two approaches dominate: operational transformation and CRDTs.
Operational transformation — Transform operations against each other. Used by classic editors
Operational transformation (OT) is how Google Docs worked for years. The idea: don't store final states, store the operations that created them.
Instead of sending "new document content = ...", you send operations like:
Insert(position: 6, text: "Kafka")Delete(position: 6, length: 5)
When two operations arrive out of order, OT transforms them against each other to preserve intent. Here's the intuition:
Alice at the server sees: "Hello World"
Alice sends: Delete(6, 5) → results in "Hello "
Bob simultaneously sends: Insert(6, "beautiful ")
The server receives both. It transforms Bob's operation: "Bob is inserting at position 6, but Alice just deleted 5 characters at position 6. Adjust Bob's position forward by 5." Bob's operation becomes Insert(11, "beautiful "), resulting in "Hello beautiful ". Then Alice's delete applies, giving "Hello ". Both clients converge to the same state through transformation.
This works, but it's mathematically complex. You need to prove that transformation is idempotent and commutative under the right conditions. Google's operational transformation implementation is still proprietary and notoriously difficult to implement correctly.
CRDTs — Data structures that merge automatically without a central coordinator
Conflict-free Replicated Data Types (CRDTs) take a different approach: design data structures so that any merge order produces the same result, without a central coordinator.
Think of it this way: instead of "who did what and in what order," CRDTs use "everyone has a replica, and they eventually agree." This is fundamentally different from OT.
A simple CRDT example is a counter with unique client IDs:
// Client A increments
counter.increment(); // Client A gets +1
// Internally: { A: 1 }
// Client B increments (offline)
counter.increment(); // Client B gets +1
// Internally: { B: 1 }
// Later, they sync
// Merge: { A: 1, B: 1 }
// Final value: 2 (sum of all contributions)
For text, CRDTs use structures like:
- Yjs array type: Every character gets a unique identifier (client ID + local clock). When two clients insert different characters at the same position, their IDs break ties deterministically.
- Automerge: Uses a variant where operations are assigned unique IDs and form a DAG (directed acyclic graph). Any topological sort of that DAG produces identical results.
The magic: CRDTs enable local-first editing. Users see changes instantly (no waiting for server confirmation), and the system reconciles conflicts automatically when replicas meet.
Use a library — Yjs and Automerge handle the hard parts
Building OT or CRDTs from scratch is a multi-year investment. The good news: battle-tested libraries exist.
Yjs is mature, performant, and widely used (Monaco Editor, Figma-like apps, Notion alternatives):
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
// Shared document
const ydoc = new Y.Doc();
const ytext = ydoc.getText('shared-text');
// Connect to server via WebSocket
const provider = new WebsocketProvider(
'ws://localhost:1234',
'my-room',
ydoc
);
// Local changes (instant, no server round-trip)
ytext.insert(0, 'Hello ');
// Listen for remote changes
ytext.observe(event => {
console.log('Remote user edited:', event.changes);
});
// When sync happens, conflicts resolve automatically
Automerge emphasizes JSON-like documents and offline-first workflows:
import * as Automerge from '@automerge/automerge';
let doc1 = Automerge.from({ text: '' });
let doc2 = Automerge.clone(doc1);
// Alice and Bob edit offline
doc1 = Automerge.change(doc1, d => {
d.text = 'Alice was here';
});
doc2 = Automerge.change(doc2, d => {
d.text = 'Bob was here';
});
// Merge (returns merged state, no conflicts lost)
const merged = Automerge.merge(doc1, doc2);
// Contains both edits in a deterministic order
For WebSocket delivery, you'd wrap either library with a protocol:
// Pseudocode: send Yjs updates over WebSocket
ytext.observe(event => {
const update = Y.encodeStateAsUpdate(ydoc);
websocket.send(JSON.stringify({ type: 'update', update }));
});
// On the other client
websocket.on('message', msg => {
if (msg.type === 'update') {
Y.applyUpdate(ydoc, msg.update);
}
});
Yjs is CRDT-based, so it's ideal for local-first, offline-capable apps. Automerge is also CRDT-based but emphasizes functional semantics (immutable snapshots). Both handle the math so you don't have to.
Common mistakes
- Sending full document state on every change: If you send the entire document instead of deltas, you'll saturate bandwidth and destroy real-time feel. Always send diffs.
- Conflating client-side latency with merge strategy: Just because Alice's edit appears on her screen instantly doesn't mean it's been persisted or merged. You still need an explicit strategy for concurrent edits—local echo doesn't solve conflicts.
- Using LWW without acknowledging data loss: If you choose last-write-wins for simplicity, know that you're silently losing edits. Document this, and only use it for low-stakes fields (like a toggle switch, not a contract).
- Not testing offline scenarios: Test what happens when a user goes offline, makes edits, then reconnects. Both Yjs and Automerge handle this, but many naive systems don't.
- Forgetting to version your CRDT format: If you upgrade Yjs or Automerge, old clients and new clients may not sync correctly. Build versioning into your wire protocol.
Key takeaways
- Collaborative editing means multiple users editing the same document simultaneously without losing work. This is a hard problem if you ignore the conflict resolution strategy.
- Operational transformation (OT) works by transforming concurrent operations against each other. It's mathematically sound but complex to implement correctly.
- CRDTs use unique identifiers and merge rules so that any merge order produces the same result. They enable local-first, offline-capable editing without a central coordinator.
- Use a battle-tested library: Yjs for performance and ecosystem support, or Automerge for functional semantics and immutable snapshots.
- Send deltas (diffs) over WebSocket, not full document state. Measure merge latency and reconciliation time in production—these matter for perceived responsiveness.
- Always test offline scenarios and reconnection logic. A robust collaborative editor handles flaky networks gracefully.






Comments
Loading comments…