Below are two independent replicas of a collaborative to-do list — a simple CRDT called an OR-Set (Observed-Remove Set). Each replica lets you add or remove items while "offline." When you click Merge, both replicas sync and instantly converge to the same state.
<p class="hint">Each column is an independent replica of a shared to-do list (an <b>OR-Set CRDT</b>).
Toggle items in either replica while “offline,” then click <b>Merge</b> to sync.
No matter what order you make changes, both replicas always converge to the same state.</p>
<div class="replicas">
<div class="replica" id="replicaA">
<div class="replica-label">Replica A <span class="badge">offline</span></div>
<ul id="listA" class="item-list"></ul>
<div class="add-row">
<input id="inputA" type="text" placeholder="Add item…" maxlength="30">
<button id="addA" type="button">Add</button>
</div>
</div>
<div class="merge-col">
<button id="mergeBtn" type="button" class="merge-btn">⇄ Merge</button>
<div class="merge-note" id="mergeNote"></div>
</div>
<div class="replica" id="replicaB">
<div class="replica-label">Replica B <span class="badge">offline</span></div>
<ul id="listB" class="item-list"></ul>
<div class="add-row">
<input id="inputB" type="text" placeholder="Add item…" maxlength="30">
<button id="addB" type="button">Add</button>
</div>
</div>
</div>
<div class="merged-view" id="mergedView" style="display:none">
<div class="merged-label">Merged state (identical on both replicas)</div>
<ul id="mergedList" class="item-list merged"></ul>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.replicas { display: flex; gap: .5rem; align-items: flex-start; }
.replica { flex: 1; border: 1.5px solid #cdd9e3; border-radius: 10px; padding: .6rem .7rem; background: #f8fafc; }
.replica-label { font-weight: 700; font-size: .82rem; color: #1d3557; margin-bottom: .4rem; display: flex; align-items: center; gap: .4rem; }
.badge { font-size: .7rem; font-weight: 600; background: #e63946; color: #fff; border-radius: 999px; padding: .1rem .45rem; }
.badge.synced { background: #0a7d33; }
.item-list { list-style: none; margin: 0 0 .5rem; padding: 0; min-height: 60px; }
.item-list li { display: flex; align-items: center; gap: .4rem; padding: .25rem 0; border-bottom: 1px solid #e8eef3; font-size: .88rem; }
.item-list li:last-child { border-bottom: none; }
.item-list li .item-text { flex: 1; }
.item-list li.removed .item-text { text-decoration: line-through; color: #999; }
.item-list.merged li { background: #f0fdf4; border-radius: 4px; padding: .25rem .4rem; margin-bottom: .15rem; border: none; }
.item-list.merged li.removed { display: none; }
.item-toggle { cursor: pointer; font-size: 1rem; background: none; border: none; padding: 0; line-height: 1; color: #1d3557; }
.item-toggle:hover { color: #e63946; }
.add-row { display: flex; gap: .35rem; }
.add-row input { flex: 1; padding: .3rem .5rem; border: 1px solid #cdd9e3; border-radius: 6px; font-size: .82rem; }
.add-row button { padding: .3rem .6rem; background: #1d3557; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: .82rem; }
.merge-col { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding-top: 2.2rem; gap: .4rem; min-width: 70px; }
.merge-btn { padding: .5rem .7rem; background: #457b9d; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .9rem; font-weight: 700; white-space: nowrap; }
.merge-btn:hover { background: #1d3557; }
.merge-note { font-size: .75rem; color: #555; text-align: center; max-width: 70px; line-height: 1.3; }
.merged-view { margin-top: .8rem; border: 1.5px solid #0a7d33; border-radius: 10px; padding: .6rem .7rem; background: #f0fdf4; }
.merged-label { font-weight: 700; font-size: .82rem; color: #0a7d33; margin-bottom: .3rem; }
// OR-Set CRDT: each item has a unique ID. Adding gives it a fresh "add-tag" per
// replica; removing tombstones all known add-tags. Merge = union of add-sets +
// union of tombstones. An item is present iff it has at least one add-tag NOT in
// the tombstone set. Commutativity, associativity and idempotency follow from set
// union — so any two replicas always converge to the same state.
let nextId = 0;
function uid() { return 'id' + (nextId++); }
// Replica state: Map<itemText, {addTags: Set<string>, tombstones: Set<string>}>
function freshReplica() { return new Map(); }
function addItem(replica, text) {
const key = text.trim().toLowerCase();
if (!key) return;
if (!replica.has(key)) replica.set(key, { addTags: new Set(), tombstones: new Set(), display: text.trim() });
replica.get(key).addTags.add(uid());
}
function removeItem(replica, key) {
if (!replica.has(key)) return;
const entry = replica.get(key);
// tombstone all current add-tags (OR-Set remove)
entry.addTags.forEach(t => entry.tombstones.add(t));
}
function isPresent(entry) {
for (const t of entry.addTags) { if (!entry.tombstones.has(t)) return true; }
return false;
}
function mergeReplicas(a, b) {
const result = new Map();
const allKeys = new Set([...a.keys(), ...b.keys()]);
allKeys.forEach(key => {
const ea = a.get(key) || { addTags: new Set(), tombstones: new Set(), display: key };
const eb = b.get(key) || { addTags: new Set(), tombstones: new Set(), display: key };
const addTags = new Set([...ea.addTags, ...eb.addTags]);
const tombstones = new Set([...ea.tombstones, ...eb.tombstones]);
result.set(key, { addTags, tombstones, display: ea.display || eb.display });
});
return result;
}
// seed initial items in both replicas
const stateA = freshReplica();
const stateB = freshReplica();
['Buy groceries', 'Call dentist', 'Read CRDT paper'].forEach(t => { addItem(stateA, t); addItem(stateB, t); });
function renderReplica(listEl, replica) {
listEl.innerHTML = '';
replica.forEach((entry, key) => {
const li = document.createElement('li');
const present = isPresent(entry);
if (!present) li.classList.add('removed');
const span = document.createElement('span');
span.className = 'item-text';
span.textContent = entry.display || key;
const btn = document.createElement('button');
btn.className = 'item-toggle';
btn.title = present ? 'Remove' : 'Add back not supported in OR-Set (use Add)';
btn.textContent = present ? '✕' : '✓';
btn.addEventListener('click', () => {
if (present) removeItem(replica, key);
render();
});
li.appendChild(span);
li.appendChild(btn);
listEl.appendChild(li);
});
}
function renderMerged(merged) {
const listEl = document.getElementById('mergedList');
listEl.innerHTML = '';
merged.forEach((entry, key) => {
if (!isPresent(entry)) return;
const li = document.createElement('li');
const span = document.createElement('span');
span.className = 'item-text';
span.textContent = entry.display || key;
li.appendChild(span);
listEl.appendChild(li);
});
}
let merged = null;
function render() {
renderReplica(document.getElementById('listA'), stateA);
renderReplica(document.getElementById('listB'), stateB);
if (merged !== null) {
merged = mergeReplicas(stateA, stateB);
renderMerged(merged);
}
// reset badges to offline after any edit
document.querySelectorAll('.badge').forEach(b => { b.textContent = 'offline'; b.classList.remove('synced'); });
if (merged !== null) {
document.querySelectorAll('.badge').forEach(b => { b.textContent = 'synced'; b.classList.add('synced'); });
}
}
function addFromInput(replica, inputId) {
const inp = document.getElementById(inputId);
const text = inp.value.trim();
if (!text) return;
addItem(replica, text);
inp.value = '';
merged = null; // mark as diverged again
render();
}
document.getElementById('addA').addEventListener('click', () => addFromInput(stateA, 'inputA'));
document.getElementById('addB').addEventListener('click', () => addFromInput(stateB, 'inputB'));
document.getElementById('inputA').addEventListener('keydown', e => { if (e.key === 'Enter') addFromInput(stateA, 'inputA'); });
document.getElementById('inputB').addEventListener('keydown', e => { if (e.key === 'Enter') addFromInput(stateB, 'inputB'); });
document.getElementById('mergeBtn').addEventListener('click', () => {
merged = mergeReplicas(stateA, stateB);
// write merged state back to both replicas so they are identical
stateA.clear(); merged.forEach((v, k) => stateA.set(k, { addTags: new Set(v.addTags), tombstones: new Set(v.tombstones), display: v.display }));
stateB.clear(); merged.forEach((v, k) => stateB.set(k, { addTags: new Set(v.addTags), tombstones: new Set(v.tombstones), display: v.display }));
document.getElementById('mergedView').style.display = '';
const note = document.getElementById('mergeNote');
const count = [...merged.values()].filter(isPresent).length;
note.textContent = count + ' ' + (count !== 1 ? 'items converged' : 'item converged');
renderMerged(merged);
render();
console.log('Merged OR-Set:', [...merged.entries()].map(([k,e]) => k + ':' + (isPresent(e)?'present':'removed')).join(', '));
});
render();
<p class="hint">Each column is an independent replica of a shared to-do list (an <b>OR-Set CRDT</b>).
Toggle items in either replica while “offline,” then click <b>Merge</b> to sync.
No matter what order you make changes, both replicas always converge to the same state.</p>
<div class="replicas">
<div class="replica" id="replicaA">
<div class="replica-label">Replica A <span class="badge">offline</span></div>
<ul id="listA" class="item-list"></ul>
<div class="add-row">
<input id="inputA" type="text" placeholder="Add item…" maxlength="30">
<button id="addA" type="button">Add</button>
</div>
</div>
<div class="merge-col">
<button id="mergeBtn" type="button" class="merge-btn">⇄ Merge</button>
<div class="merge-note" id="mergeNote"></div>
</div>
<div class="replica" id="replicaB">
<div class="replica-label">Replica B <span class="badge">offline</span></div>
<ul id="listB" class="item-list"></ul>
<div class="add-row">
<input id="inputB" type="text" placeholder="Add item…" maxlength="30">
<button id="addB" type="button">Add</button>
</div>
</div>
</div>
<div class="merged-view" id="mergedView" style="display:none">
<div class="merged-label">Merged state (identical on both replicas)</div>
<ul id="mergedList" class="item-list merged"></ul>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: 14px; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .8rem; line-height: 1.5; }
.replicas { display: flex; gap: .5rem; align-items: flex-start; }
.replica { flex: 1; border: 1.5px solid #cdd9e3; border-radius: 10px; padding: .6rem .7rem; background: #f8fafc; }
.replica-label { font-weight: 700; font-size: .82rem; color: #1d3557; margin-bottom: .4rem; display: flex; align-items: center; gap: .4rem; }
.badge { font-size: .7rem; font-weight: 600; background: #e63946; color: #fff; border-radius: 999px; padding: .1rem .45rem; }
.badge.synced { background: #0a7d33; }
.item-list { list-style: none; margin: 0 0 .5rem; padding: 0; min-height: 60px; }
.item-list li { display: flex; align-items: center; gap: .4rem; padding: .25rem 0; border-bottom: 1px solid #e8eef3; font-size: .88rem; }
.item-list li:last-child { border-bottom: none; }
.item-list li .item-text { flex: 1; }
.item-list li.removed .item-text { text-decoration: line-through; color: #999; }
.item-list.merged li { background: #f0fdf4; border-radius: 4px; padding: .25rem .4rem; margin-bottom: .15rem; border: none; }
.item-list.merged li.removed { display: none; }
.item-toggle { cursor: pointer; font-size: 1rem; background: none; border: none; padding: 0; line-height: 1; color: #1d3557; }
.item-toggle:hover { color: #e63946; }
.add-row { display: flex; gap: .35rem; }
.add-row input { flex: 1; padding: .3rem .5rem; border: 1px solid #cdd9e3; border-radius: 6px; font-size: .82rem; }
.add-row button { padding: .3rem .6rem; background: #1d3557; color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: .82rem; }
.merge-col { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; padding-top: 2.2rem; gap: .4rem; min-width: 70px; }
.merge-btn { padding: .5rem .7rem; background: #457b9d; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-size: .9rem; font-weight: 700; white-space: nowrap; }
.merge-btn:hover { background: #1d3557; }
.merge-note { font-size: .75rem; color: #555; text-align: center; max-width: 70px; line-height: 1.3; }
.merged-view { margin-top: .8rem; border: 1.5px solid #0a7d33; border-radius: 10px; padding: .6rem .7rem; background: #f0fdf4; }
.merged-label { font-weight: 700; font-size: .82rem; color: #0a7d33; margin-bottom: .3rem; }
// OR-Set CRDT: each item has a unique ID. Adding gives it a fresh "add-tag" per
// replica; removing tombstones all known add-tags. Merge = union of add-sets +
// union of tombstones. An item is present iff it has at least one add-tag NOT in
// the tombstone set. Commutativity, associativity and idempotency follow from set
// union — so any two replicas always converge to the same state.
let nextId = 0;
function uid() { return 'id' + (nextId++); }
// Replica state: Map<itemText, {addTags: Set<string>, tombstones: Set<string>}>
function freshReplica() { return new Map(); }
function addItem(replica, text) {
const key = text.trim().toLowerCase();
if (!key) return;
if (!replica.has(key)) replica.set(key, { addTags: new Set(), tombstones: new Set(), display: text.trim() });
replica.get(key).addTags.add(uid());
}
function removeItem(replica, key) {
if (!replica.has(key)) return;
const entry = replica.get(key);
// tombstone all current add-tags (OR-Set remove)
entry.addTags.forEach(t => entry.tombstones.add(t));
}
function isPresent(entry) {
for (const t of entry.addTags) { if (!entry.tombstones.has(t)) return true; }
return false;
}
function mergeReplicas(a, b) {
const result = new Map();
const allKeys = new Set([...a.keys(), ...b.keys()]);
allKeys.forEach(key => {
const ea = a.get(key) || { addTags: new Set(), tombstones: new Set(), display: key };
const eb = b.get(key) || { addTags: new Set(), tombstones: new Set(), display: key };
const addTags = new Set([...ea.addTags, ...eb.addTags]);
const tombstones = new Set([...ea.tombstones, ...eb.tombstones]);
result.set(key, { addTags, tombstones, display: ea.display || eb.display });
});
return result;
}
// seed initial items in both replicas
const stateA = freshReplica();
const stateB = freshReplica();
['Buy groceries', 'Call dentist', 'Read CRDT paper'].forEach(t => { addItem(stateA, t); addItem(stateB, t); });
function renderReplica(listEl, replica) {
listEl.innerHTML = '';
replica.forEach((entry, key) => {
const li = document.createElement('li');
const present = isPresent(entry);
if (!present) li.classList.add('removed');
const span = document.createElement('span');
span.className = 'item-text';
span.textContent = entry.display || key;
const btn = document.createElement('button');
btn.className = 'item-toggle';
btn.title = present ? 'Remove' : 'Add back not supported in OR-Set (use Add)';
btn.textContent = present ? '✕' : '✓';
btn.addEventListener('click', () => {
if (present) removeItem(replica, key);
render();
});
li.appendChild(span);
li.appendChild(btn);
listEl.appendChild(li);
});
}
function renderMerged(merged) {
const listEl = document.getElementById('mergedList');
listEl.innerHTML = '';
merged.forEach((entry, key) => {
if (!isPresent(entry)) return;
const li = document.createElement('li');
const span = document.createElement('span');
span.className = 'item-text';
span.textContent = entry.display || key;
li.appendChild(span);
listEl.appendChild(li);
});
}
let merged = null;
function render() {
renderReplica(document.getElementById('listA'), stateA);
renderReplica(document.getElementById('listB'), stateB);
if (merged !== null) {
merged = mergeReplicas(stateA, stateB);
renderMerged(merged);
}
// reset badges to offline after any edit
document.querySelectorAll('.badge').forEach(b => { b.textContent = 'offline'; b.classList.remove('synced'); });
if (merged !== null) {
document.querySelectorAll('.badge').forEach(b => { b.textContent = 'synced'; b.classList.add('synced'); });
}
}
function addFromInput(replica, inputId) {
const inp = document.getElementById(inputId);
const text = inp.value.trim();
if (!text) return;
addItem(replica, text);
inp.value = '';
merged = null; // mark as diverged again
render();
}
document.getElementById('addA').addEventListener('click', () => addFromInput(stateA, 'inputA'));
document.getElementById('addB').addEventListener('click', () => addFromInput(stateB, 'inputB'));
document.getElementById('inputA').addEventListener('keydown', e => { if (e.key === 'Enter') addFromInput(stateA, 'inputA'); });
document.getElementById('inputB').addEventListener('keydown', e => { if (e.key === 'Enter') addFromInput(stateB, 'inputB'); });
document.getElementById('mergeBtn').addEventListener('click', () => {
merged = mergeReplicas(stateA, stateB);
// write merged state back to both replicas so they are identical
stateA.clear(); merged.forEach((v, k) => stateA.set(k, { addTags: new Set(v.addTags), tombstones: new Set(v.tombstones), display: v.display }));
stateB.clear(); merged.forEach((v, k) => stateB.set(k, { addTags: new Set(v.addTags), tombstones: new Set(v.tombstones), display: v.display }));
document.getElementById('mergedView').style.display = '';
const note = document.getElementById('mergeNote');
const count = [...merged.values()].filter(isPresent).length;
note.textContent = count + ' ' + (count !== 1 ? 'items converged' : 'item converged');
renderMerged(merged);
render();
console.log('Merged OR-Set:', [...merged.entries()].map(([k,e]) => k + ':' + (isPresent(e)?'present':'removed')).join(', '));
});
render();
Notice: it does not matter which replica you edited first, or how many times you toggled items. The merge is always the same. This is the commutativity and idempotency guarantee at work. Related: the same idea powers the distributed consensus behind P vs NP and Nash equilibrium games where agents must reach agreement.
Comments
Loading comments...