How to Auto-Merge Concurrent Edit Conflicts in Real-Time Collaborative Apps with CRDT and LWW
The first time I built a real-time collaboration feature, I spent the whole night wrestling with this question: "What happens when two people edit the same field at the same time?" Locking felt like it would destroy usability, but silently overwriting with the latest write meant one person's work would quietly vanish. I shipped the overwrite approach first, and only after watching actual user data disappear did I dig into this properly. Get this decision wrong and data disappears without a single bug report — users don't even realize what they typed is gone.
CRDT (Conflict-free Replicated Data Type) is the core technology behind services like Notion, Figma, and Linear that enables multiple people to edit simultaneously. LWW (Last-Write-Wins) is a far simpler strategy that distributed databases like Cassandra and DynamoDB have used for decades. Both answer the question "how do we resolve conflicts?" — but their philosophies are completely different. CRDT designs the structure so that conflicts never arise in the first place, while LWW accepts that conflicts will happen and cleanly discards the loser.
After reading this, you'll understand how CRDT and LWW each work under the hood, and you'll be able to judge for yourself which strategy fits which fields in your current project.
Core Concepts
Where CRDT and LWW Diverge
Let's start with a bird's-eye view of the key differences between the two. The details will follow naturally as you read — but having the full picture first makes everything much easier to absorb.
| Criterion | CRDT | LWW |
|---|---|---|
| Conflict handling | Merges all updates — no data loss | Highest timestamp wins — loser is discarded |
| Convergence guarantee | Mathematically guaranteed (Strong Eventual Consistency) | Guaranteed, but vulnerable to clock skew |
| Implementation complexity | High — correct design requires mathematical understanding | Low — just compare timestamps |
| Metadata overhead | High (vector clocks, unique IDs, tombstones, etc.) | Negligible |
| Best fit | Collaborative features where users can edit the same field simultaneously | Fields where only the latest value matters (settings, login timestamps, etc.) |
The Fundamental Problem in Distributed Systems
The network can go down at any time. Two nodes can simultaneously modify the same data while offline. The CAP theorem says that in this situation — the moment a partition occurs — you must choose between consistency and availability. In a healthy state you can have both, but when the network splits, you're forced to pick. Most modern distributed systems choose availability — they allow reads and writes during a partition and resolve conflicts after reconnection.
CRDT and LWW are two different answers to that question: "how do we resolve conflicts after reconnection?"
CRDT — A Data Structure with Mathematically Guaranteed Merge Operations
CRDT (Conflict-free Replicated Data Type): A data structure that mathematically guarantees that even when multiple distributed nodes independently modify data without a central server, all replicas will eventually converge to the same state.
What CRDT guarantees isn't simple Eventual Consistency (EC). It's Strong Eventual Consistency (SEC). Regular EC is a loose promise that "things will eventually agree," while SEC is a stronger guarantee that "two nodes that have received the same set of updates are immediately in an identical state." This difference is what makes CRDT usable in collaborative editors.
The secret to this guarantee is that the merge() operation satisfies three mathematical laws.
| Law | Meaning | Practical effect |
|---|---|---|
| Commutativity | merge(A, B) = merge(B, A) |
Result is the same regardless of arrival order |
| Associativity | merge(merge(A, B), C) = merge(A, merge(B, C)) |
Result is the same regardless of merge order |
| Idempotency | merge(A, A) = A |
Duplicate message delivery is fine |
When all three are guaranteed, no matter what order updates propagate after a network partition reconnects, the system ends up in the same state. That's why no central coordinator is needed.
CRDTs come in two varieties based on implementation. State-based (CvRDT) transmits the full state and the receiver merges with merge(). Simpler to implement, but payloads are large. Operation-based (CmRDT) propagates only the change operations to reduce network load, but requires infrastructure guarantees that messages are delivered exactly once. Yjs actually uses something closer to Delta CRDT — it only propagates changed portions, so it's bandwidth-efficient. The GCounter example below uses a state-based approach to illustrate the concept.
// State-based CRDT (CvRDT) — transmits full state and merges with merge()
class GCounter {
private counts: Map<string, number> = new Map();
increment(nodeId: string): void {
this.counts.set(nodeId, (this.counts.get(nodeId) ?? 0) + 1);
}
value(): number {
return [...this.counts.values()].reduce((sum, v) => sum + v, 0);
}
merge(other: GCounter): GCounter {
const merged = new GCounter();
const allKeys = new Set([...this.counts.keys(), ...other.counts.keys()]);
for (const key of allKeys) {
merged.counts.set(key, Math.max(
this.counts.get(key) ?? 0,
other.counts.get(key) ?? 0
));
}
return merged;
}
}
// Node A and B each increment their counter, then merge later
const nodeA = new GCounter();
nodeA.increment('nodeA');
nodeA.increment('nodeA'); // nodeA: 2
const nodeB = new GCounter();
nodeB.increment('nodeB'); // nodeB: 1
const merged = nodeA.merge(nodeB);
console.log(merged.value()); // 3 — always correct regardless of arrival orderCRDTs range from simple types like G-Counter (increment-only) to complex Sequence CRDTs used for text editing.
LWW — A Simple, Practical Strategy That Uses Timestamps to Pick a Winner
LWW (Last-Write-Wins): A strategy where, among conflicting writes, the one with the largest timestamp "wins." The losing write is discarded.
LWW can also be mathematically implemented as a type of CRDT — the merge() function simply "selects the value with the larger timestamp." I ran into a consistency bug in my first implementation of this by writing the tie-breaking logic for write() and merge() separately. If two writes arrive within the same millisecond, write() would fail both, while merge() would decide by nodeId — an inconsistency. The implementation below fixes that issue.
interface LWWValue<T> {
value: T;
timestamp: number;
nodeId: string; // for same-timestamp tie-breaking
}
class LWWRegister<T> {
private nodeId: string;
private state: LWWValue<T>;
constructor(initialValue: T, nodeId: string) {
this.nodeId = nodeId;
this.state = { value: initialValue, timestamp: 0, nodeId };
}
write(value: T): void {
const timestamp = Date.now();
// write() and merge() must use the same tie-breaking criteria for consistency
if (
timestamp > this.state.timestamp ||
(timestamp === this.state.timestamp && this.nodeId > this.state.nodeId)
) {
this.state = { value, timestamp, nodeId: this.nodeId };
}
}
read(): T {
return this.state.value;
}
merge(other: LWWRegister<T>): LWWRegister<T> {
const winner =
other.state.timestamp > this.state.timestamp
? other.state
: other.state.timestamp === this.state.timestamp && other.state.nodeId > this.state.nodeId
? other.state
: this.state;
const result = new LWWRegister<T>(winner.value, winner.nodeId);
result.state = winner;
return result;
}
}While LWW-Register handles a single value, LWW-Element-Set works by attaching a timestamp to each element in a set and comparing the "time added" against the "time removed."
class LWWElementSet<T> {
private addSet: Map<T, number> = new Map();
private removeSet: Map<T, number> = new Map();
add(element: T, timestamp: number): void {
const existing = this.addSet.get(element) ?? -Infinity;
if (timestamp > existing) {
this.addSet.set(element, timestamp);
}
}
remove(element: T, timestamp: number): void {
const existing = this.removeSet.get(element) ?? -Infinity;
if (timestamp > existing) {
this.removeSet.set(element, timestamp);
}
}
has(element: T): boolean {
const addTime = this.addSet.get(element) ?? -Infinity;
const removeTime = this.removeSet.get(element) ?? -Infinity;
return addTime >= removeTime;
}
merge(other: LWWElementSet<T>): void {
for (const [elem, ts] of other.addSet) this.add(elem, ts);
for (const [elem, ts] of other.removeSet) this.remove(elem, ts);
}
}Practical Application
Example 1: Using Yjs in a Real-Time Collaborative Editor
Implementing a CRDT from scratch is honestly not easy. Building a proper text Sequence CRDT requires reading through several research papers. In practice, using a proven library like Yjs is far more realistic. Yjs is the most widely used JavaScript CRDT library with over 900,000 weekly downloads — similar to how Notion and Linear actually use it.
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
// Create a shared document
const ydoc = new Y.Doc()
// Sync with other clients over WebSocket
const provider = new WebsocketProvider('wss://your-server.com', 'room-name', ydoc)
// Shared text type — CRDT handles merging even when multiple people edit simultaneously
const ytext = ydoc.getText('content')
// User A types "Hello"
ytext.insert(0, 'Hello')
// User B, while offline, simultaneously appends " World", then reconnects
// → Yjs auto-merges using the YATA algorithm: "Hello World"
// Collaborative counter (e.g. likes) — Y.Map resolves conflicts via LWW
const ymap = ydoc.getMap('metadata')
ymap.set('likes', (ymap.get('likes') as number ?? 0) + 1)
// Observe changes
ytext.observe(() => {
console.log('Text changed:', ytext.toString())
})| Component | Role |
|---|---|
Y.Doc |
CRDT document root, manages all shared types |
WebsocketProvider |
Handles network sync, stores locally when offline |
Y.Text |
Sequence CRDT, auto-resolves text insert/delete conflicts |
Y.Map |
Key-value CRDT, resolves conflicts via LWW |
To get started, it's recommended to grab an example that matches your environment from the examples folder in the Yjs official repo.
Example 2: Shopping Cart with Offline Support
A pattern for mobile apps where adding/removing items from the cart works even without internet connectivity, and merges with the server on reconnection. Shopping carts are modified simultaneously across multiple tabs or devices fairly often, making them a case where conflicts occur regularly.
import * as Y from 'yjs'
import { IndexeddbPersistence } from 'y-indexeddb'
class OfflineShoppingCart {
private ydoc: Y.Doc
private items: Y.Map<number> // itemId -> quantity
constructor(userId: string) {
this.ydoc = new Y.Doc()
this.items = this.ydoc.getMap('cart')
// Auto-saves to local IndexedDB — data persists even when the app is closed
new IndexeddbPersistence(`cart-${userId}`, this.ydoc)
}
addItem(itemId: string, quantity: number): void {
const current = this.items.get(itemId) ?? 0
this.items.set(itemId, current + quantity)
}
removeItem(itemId: string): void {
this.items.delete(itemId)
}
getItems(): Record<string, number> {
return Object.fromEntries(this.items.entries())
}
// Merge with state received from server (on reconnection)
mergeWithServer(serverUpdate: Uint8Array): void {
Y.applyUpdate(this.ydoc, serverUpdate)
// CRDT guarantee: final result is identical regardless of merge order
}
}Example 3: Cassandra-Style LWW — Storing Last Login Time
This is a textbook case where LWW is a perfect fit. A user's "last login time" is, by definition, meaningful only as the most recent value. There is no reason to preserve the previous value, and forcing CRDT into this situation only adds complexity.
interface UserSession {
userId: string;
lastLoginAt: number; // Unix timestamp (ms)
deviceInfo: string;
}
class UserSessionStore {
private sessions: Map<string, UserSession> = new Map();
upsert(session: UserSession): void {
const existing = this.sessions.get(session.userId);
// LWW: more recent timestamp wins
if (!existing || session.lastLoginAt > existing.lastLoginAt) {
this.sessions.set(session.userId, session);
}
}
// Merge state from two nodes (e.g. cross-region replication)
merge(other: UserSessionStore): void {
for (const [, session] of other.sessions) {
this.upsert(session);
}
}
}
// What this TypeScript class does manually, Cassandra handles automatically at the schema level.
// CREATE TABLE user_sessions (
// user_id uuid PRIMARY KEY,
// last_login_at timestamp,
// device_info text
// );
// -- Writes to the same partition key automatically apply LWW (based on write time)This simplicity is exactly why Cassandra has used LWW as its default conflict resolution strategy for decades. It fits data where "the latest is the truth" — login timestamps, user settings, read receipts.
Pros and Cons
CRDT Advantages
| Item | Description |
|---|---|
| Coordination-free convergence | All replicas converge to the same state without a central server |
| High availability | Reads and writes continue working even during a network partition |
| Offline support | Local edits work without a connection and auto-merge on reconnect |
| No data loss | All changes are reflected on conflict — the loser is never discarded |
| Horizontal scalability | P2P structure lets you add nodes without a central bottleneck |
CRDT Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Memory overhead | Merge metadata (vector clocks, unique IDs, etc.) accumulates over time | Design a garbage collection strategy; use snapshot compression |
| Difficult deletions | Delete operations must leave tombstones, causing data to grow continuously | Periodic tombstone GC, or use OR-Set |
| No strong consistency | Momentary inconsistencies exist (Strong Eventual Consistency) | Design strong-consistency logic separately |
| Implementation complexity | Correct CRDT design requires mathematical understanding | Use proven libraries like Yjs or Automerge |
LWW Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Clock skew | Clock drift between distributed nodes can cause a logically later write to be overwritten | Replace with HLC (Hybrid Logical Clock) or vector clocks |
| Data loss | The losing write is permanently discarded | Only apply LWW to fields where loss is acceptable |
| Naive tie-breaking | Inconsistent handling of identical timestamps across implementations can cause divergence | Use the same tie-breaking logic in both write() and merge() |
Term note — Clock Skew: The error that arises because clocks across distributed machines are never perfectly synchronized. Even with NTP, errors of hundreds of milliseconds can occur, meaning physical-clock-based LWW can produce reversals where a logically later event carries an earlier timestamp.
Term note — Tombstone: In CRDTs, when an element is deleted, instead of actually removing it, a marker is left behind indicating "this was deleted." This is necessary because other nodes may still reference the element before learning about its deletion.
Term note — Vector Clock: An approach where each node maintains a vector of logical clocks for itself and all nodes it knows about. Unlike physical clocks, vector clocks can track causality (which event happened before which), avoiding clock skew problems. HLC (Hybrid Logical Clock) is a practical alternative that combines the intuitiveness of a physical clock with the causal tracking of a vector clock.
The Most Common Mistakes in Practice
-
Using a physical clock directly as the LWW timestamp. I did this myself early on — using
Date.now()as the LWW timestamp — and watched data disappear in a staging environment. Two servers had clocks that differed by 200ms, and writes that logically happened later were losing the timestamp race. HLC (Hybrid Logical Clock) is strongly recommended. -
Not designing tombstone GC. If you use an OR-Set-based CRDT without periodically cleaning up tombstones for deleted elements, memory and disk will fill up with tombstones over time. It's worth thinking at design time about "when can tombstones be safely deleted."
-
Applying CRDT to every conflict. For fields where the later value is always semantically correct — like "last login time" — LWW is far simpler and more appropriate. CRDT has a complexity cost, so it's best reserved for cases where every change genuinely needs to be preserved.
Closing Thoughts
If users can edit the same field simultaneously, CRDT is the right fit. If only the final state matters, LWW is. This single line covers most decision scenarios. Text editors and shared whiteboards belong in the CRDT camp; login timestamps, read receipts, and last-saved settings belong in the LWW camp.
There are three steps you can take right now.
- Open An Interactive Intro to CRDTs on jakelazaroff.com and actually play with how CRDT merging works in the browser. Seeing is believing — this demo will give you a feel for it faster than any long explanation.
- Look for fields in your current project where LWW is a good fit. Fields like "last modified time," "most recent setting," and "read receipt" are likely already implicitly expecting LWW behavior. Making that explicit in your design will make things much easier when multi-region or offline support becomes a requirement later.
- Run
pnpm add yjs y-websocketand wire up something simple like a shared counter or a shared checklist. The examples folder in the Yjs official repo has starting points for a variety of environments. Getting something small actually running is the fastest way to develop intuition for CRDTs.
References
- About CRDTs | crdt.tech
- Conflict-free replicated data type | Wikipedia
- Approaches to Conflict-free Replicated Data Types | ACM Computing Surveys (2024)
- An Interactive Intro to CRDTs | jakelazaroff.com
- Conflict Resolution: Using Last-Write-Wins vs. CRDTs | DZone
- Operation-based CRDTs: registers and sets | Bartosz Sypytkowski
- CRDTs solve distributed data consistency challenges | Ably
- Diving into CRDTs | Redis Blog
- What are CRDTs | Loro Docs
- Building real-time collaboration: OT vs CRDT | TinyMCE
- CRDT Survey, Part 2: Semantic Techniques | Matthew Weidner