Split View: CRDT 완전 가이드 2025: 충돌 없는 복제 데이터 타입, 로컬 우선 협업, Yjs/Automerge
CRDT 완전 가이드 2025: 충돌 없는 복제 데이터 타입, 로컬 우선 협업, Yjs/Automerge
TL;DR
- CRDT의 마법: 여러 사용자가 동시에 다른 복제본을 수정해도 자동으로 같은 상태로 수렴. 락 없이, 중앙 서버 없이
- 두 가지 접근: State-based(전체 상태 전송) vs Operation-based(연산 전송). 각각 장단점
- 로컬 우선 운동: Ink & Switch 연구소가 시작. Figma, Linear, Notion이 채택
- Yjs vs Automerge: Yjs는 성능과 텍스트 협업, Automerge는 JSON 데이터 모델에 강함
- 언제 사용: 협업 에디터, 오프라인 우선 앱, P2P 동기화, 분산 메모
1. CRDT란 무엇인가?
1.1 문제 — 분산 환경에서의 충돌
시나리오: 두 사용자가 동시에 같은 문서를 편집합니다.
초기: "Hello"
User A 추가: "Hello World"
User B 동시 추가: "Hello Friend"
병합 시 어떻게? 어떤 게 이기나?
전통적 해결책:
- 마지막 쓰기 승리(Last Write Wins) — 한 사람의 변경이 사라짐
- 수동 병합 — 사용자가 직접 해결
- 락(Locking) — 한 명만 편집 가능, 협업 불가능
CRDT의 답: 두 변경을 자동으로 의미 있게 병합. 둘 다 보존.
1.2 CRDT의 수학적 정의
CRDT는 두 가지 속성을 가진 데이터 타입입니다:
- 결합법칙(Associativity):
(a + b) + c = a + (b + c) - 교환법칙(Commutativity):
a + b = b + a - 멱등성(Idempotence):
a + a = a
이 세 가지를 만족하면, 어떤 순서로 병합해도 결과가 동일합니다. 이를 ACI 속성 또는 semilattice라고 합니다.
1.3 두 가지 접근
State-based CRDT (CvRDT)
- 전체 상태를 전송
- 병합 함수:
merge(a, b) → c - 장점: 단순, 메시지 순서 무관
- 단점: 큰 상태 = 큰 네트워크 비용
Operation-based CRDT (CmRDT)
- 연산만 전송
- 모든 노드가 같은 연산을 같은 횟수 적용해야 함
- 장점: 작은 메시지
- 단점: 안정적인 메시지 전달 필요 (at-least-once)
실용적 선택: 대부분의 라이브러리(Yjs, Automerge)는 하이브리드 접근을 사용합니다.
2. 기본 CRDT 타입
2.1 G-Counter (Grow-only Counter)
증가만 가능한 카운터.
class GCounter:
def __init__(self, node_id):
self.node_id = node_id
self.counts = {} # {node_id: count}
def increment(self):
self.counts[self.node_id] = self.counts.get(self.node_id, 0) + 1
def value(self):
return sum(self.counts.values())
def merge(self, other):
# 각 노드의 카운트는 max로 병합
for node, count in other.counts.items():
self.counts[node] = max(self.counts.get(node, 0), count)
왜 max?: 같은 노드의 카운트는 단조 증가(monotonic). max를 취하면 어떤 노드가 더 최신인지 자동 결정.
사용 예: 페이지 뷰 카운트, 좋아요 수.
2.2 PN-Counter (Positive-Negative Counter)
증가/감소 모두 가능.
class PNCounter:
def __init__(self, node_id):
self.positive = GCounter(node_id) # 증가용
self.negative = GCounter(node_id) # 감소용
def increment(self):
self.positive.increment()
def decrement(self):
self.negative.increment()
def value(self):
return self.positive.value() - self.negative.value()
def merge(self, other):
self.positive.merge(other.positive)
self.negative.merge(other.negative)
핵심 트릭: 두 개의 G-Counter로 분리. 감소는 "음수의 증가"로 표현.
2.3 G-Set (Grow-only Set)
요소 추가만 가능한 Set.
class GSet:
def __init__(self):
self.elements = set()
def add(self, e):
self.elements.add(e)
def merge(self, other):
self.elements |= other.elements # 합집합
한계: 삭제 불가능. 영원히 자라는 Set.
2.4 2P-Set (Two-Phase Set)
추가와 삭제 모두 가능. 단, 한 번 삭제된 요소는 다시 추가 불가.
class TwoPhaseSet:
def __init__(self):
self.added = set()
self.removed = set()
def add(self, e):
if e not in self.removed:
self.added.add(e)
def remove(self, e):
self.removed.add(e)
def contains(self, e):
return e in self.added and e not in self.removed
def merge(self, other):
self.added |= other.added
self.removed |= other.removed
한계: tombstone(삭제 표시)이 영원히 누적.
2.5 LWW-Register (Last-Write-Wins Register)
타임스탬프 기반의 단일 값.
class LWWRegister:
def __init__(self):
self.value = None
self.timestamp = 0
def write(self, value, timestamp):
if timestamp > self.timestamp:
self.value = value
self.timestamp = timestamp
def merge(self, other):
if other.timestamp > self.timestamp:
self.value = other.value
self.timestamp = other.timestamp
문제: 시계 동기화. **Hybrid Logical Clock(HLC)**로 해결.
2.6 OR-Set (Observed-Remove Set)
가장 실용적인 Set CRDT. 추가/삭제 자유.
class ORSet:
def __init__(self):
# {element: set of unique tags}
self.elements = defaultdict(set)
self.tombstones = defaultdict(set)
def add(self, e):
tag = uuid.uuid4()
self.elements[e].add(tag)
def remove(self, e):
# 현재 보이는 모든 태그를 tombstone에
self.tombstones[e] |= self.elements[e]
def contains(self, e):
return bool(self.elements[e] - self.tombstones[e])
def merge(self, other):
for e, tags in other.elements.items():
self.elements[e] |= tags
for e, tags in other.tombstones.items():
self.tombstones[e] |= tags
핵심 아이디어: 각 추가에 고유 태그. 삭제는 "현재 본 태그들"을 tombstone에 추가. 새 추가는 새 태그 → 살아남음.
3. 텍스트 협업 — 가장 어려운 CRDT
3.1 왜 텍스트가 어려운가?
시나리오: 두 사용자가 같은 위치에 동시에 삽입.
초기: "ABCDE"
User A: 위치 2에 "X" 삽입 → "ABXCDE"
User B: 위치 2에 "Y" 삽입 → "ABYCDE"
병합: "ABXYCDE" 또는 "ABYXCDE"?
문제: 위치는 상대적입니다. 한 사용자의 삽입이 다른 사용자의 위치를 무효화.
3.2 RGA (Replicated Growable Array)
각 문자에 고유 ID를 부여. 위치 대신 ID를 참조.
초기 문서:
[start] - A(id1) - B(id2) - C(id3) - [end]
User A: B 다음에 X 삽입 (id4)
[start] - A(id1) - B(id2) - X(id4) - C(id3) - [end]
User B: B 다음에 Y 삽입 (id5)
[start] - A(id1) - B(id2) - Y(id5) - C(id3) - [end]
병합 (id로 비교, 작은 id가 먼저):
[start] - A(id1) - B(id2) - X(id4) - Y(id5) - C(id3) - [end]
핵심: 고유 ID + 정렬 규칙으로 결정적 병합.
3.3 Yjs의 YATA 알고리즘
Yjs는 **YATA(Yet Another Transformation Approach)**라는 변형 알고리즘 사용. RGA보다 효율적입니다.
각 문자가 다음을 가집니다:
- 고유 ID
(client_id, clock) origin_left: 삽입 시 왼쪽 이웃의 IDorigin_right: 삽입 시 오른쪽 이웃의 ID
병합 규칙:
- 같은 origin을 가진 삽입들은 client_id 순으로 정렬
- origin이 다르면 위치 정보로 정렬
효율성: Yjs는 같은 문자열의 연속 입력을 단일 객체로 압축 → 메모리 100배+ 효율.
4. Yjs — JavaScript CRDT의 표준
4.1 기본 사용법
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
// 공유 문서 생성
const doc = new Y.Doc()
// 공유 텍스트
const ytext = doc.getText('shared-text')
ytext.insert(0, 'Hello, ')
ytext.insert(7, 'World!')
console.log(ytext.toString()) // "Hello, World!"
// P2P 동기화 (WebRTC)
const provider = new WebrtcProvider('my-room', doc)
4.2 다양한 데이터 타입
// 텍스트
const ytext = doc.getText('text')
ytext.insert(0, 'Hello')
// 배열
const yarray = doc.getArray('list')
yarray.push(['item1', 'item2'])
// 맵 (객체)
const ymap = doc.getMap('config')
ymap.set('theme', 'dark')
// XML/JSON
const yxml = doc.getXmlFragment('content')
4.3 변경 감지
ytext.observe((event) => {
console.log('Changes:', event.changes.delta)
// [{ retain: 7 }, { insert: 'World!' }]
})
4.4 영속화 — IndexedDB
import { IndexeddbPersistence } from 'y-indexeddb'
const persistence = new IndexeddbPersistence('my-doc', doc)
persistence.on('synced', () => {
console.log('Loaded from IndexedDB')
})
브라우저 새로고침 후에도 상태 유지 — 진정한 로컬 우선.
4.5 다중 프로바이더
Yjs는 transport-agnostic — 여러 동기화 방식 지원:
y-websocket— 중앙 서버 동기화y-webrtc— P2P 동기화y-indexeddb— 로컬 영속화y-leveldb— Node.js 서버- 커스텀 프로바이더 작성 가능
5. Automerge — JSON 데이터 모델
5.1 JSON 친화적 인터페이스
import * as Automerge from '@automerge/automerge'
let doc = Automerge.init()
doc = Automerge.change(doc, 'Initial', d => {
d.todos = []
d.todos.push({ text: 'Buy milk', done: false })
})
// 다른 디바이스
let doc2 = Automerge.merge(Automerge.init(), doc)
doc2 = Automerge.change(doc2, 'Add task', d => {
d.todos.push({ text: 'Walk dog', done: false })
})
// 병합
const merged = Automerge.merge(doc, doc2)
console.log(merged.todos) // [Buy milk, Walk dog]
5.2 Yjs vs Automerge
| Yjs | Automerge | |
|---|---|---|
| 작성 언어 | JavaScript | TypeScript + WASM |
| 데이터 모델 | CRDT 타입 (Y.Text, Y.Map) | JSON-like |
| 텍스트 성능 | 매우 빠름 | 좋음 |
| 메모리 효율 | 우수 | 보통 |
| 학습 곡선 | 중간 | 낮음 |
| 사용 사례 | 협업 에디터 | 일반 데이터 동기화 |
| 문서 크기 | 작음 | 보통 |
| 사용처 | Notion, Linear, Affine | Local-first 앱 |
선택 가이드:
- 텍스트 협업 (에디터, 노트): Yjs
- JSON 데이터 (앱 상태, 폼): Automerge
- 둘 다 고려: 프로토타입은 Automerge가 빠름
6. Local-First Software 운동
6.1 7가지 이상
Ink & Switch 연구소의 Local-First Software 매니페스토:
- 빠르다 — 네트워크 왕복 없음
- 다중 디바이스 작동 — 동기화는 백그라운드
- 네트워크 선택적 — 오프라인에서 완전 작동
- 다른 사람과 협업 — CRDT로 충돌 해결
- 장기적 보존 — 클라우드가 사라져도 데이터 보존
- 기본적 보안과 프라이버시 — 데이터는 사용자 디바이스에
- 궁극적 사용자 통제 — 데이터 소유권
6.2 클라우드 우선 vs 로컬 우선
| 클라우드 우선 | 로컬 우선 | |
|---|---|---|
| 데이터 위치 | 서버 | 디바이스 |
| 오프라인 | 작동 X | 완전 작동 |
| 응답 시간 | 네트워크 의존 | 즉시 |
| 협업 | 서버 중계 | P2P 또는 서버 |
| 회사 폐업 | 데이터 손실 | 데이터 유지 |
| 예시 | Google Docs | Obsidian, Logseq, Linear |
6.3 로컬 우선 채택 사례
Linear — 프로젝트 관리:
- 전체 데이터를 로컬 IndexedDB에
- 즉시 응답 (latency 0)
- WebSocket으로 백그라운드 동기화
- 오프라인 변경은 큐에 저장 후 재연결 시 전송
Figma — 디자인 협업:
- 자체 CRDT 구현 (RGA 기반)
- 실시간 멀티 커서
- 오프라인 편집 후 동기화
Affine — 노트:
- Yjs 사용
- 완전 로컬 우선
- 클라우드는 선택적 동기화
Notion — 노트/위키:
- 부분적 CRDT (블록 단위)
- 텍스트 편집은 OT에서 CRDT로 마이그레이션 중
7. CRDT vs OT (Operational Transformation)
7.1 OT란?
Google Docs가 사용한 전통적 협업 방식. 연산 변환 — 동시 연산을 변환하여 일관성 유지.
초기: "ABC"
User A: insert(1, "X") → "AXBC"
User B: delete(2) → "AB"
User B의 연산을 User A의 변경 후로 변환:
delete(2) → delete(3) (위치 조정)
결과: "AXB"
7.2 비교
| OT | CRDT | |
|---|---|---|
| 중앙 서버 | 필수 | 선택 |
| 오프라인 지원 | 어려움 | 자연스러움 |
| 알고리즘 복잡도 | 매우 복잡 | 복잡 (하지만 검증 가능) |
| P2P | 어려움 | 자연스러움 |
| Google Docs | ✅ (현재) | ❌ |
| Figma, Linear | ❌ | ✅ |
| 학계 연구 | 1990년대~ | 2000년대~ |
트렌드: OT → CRDT 마이그레이션 (Notion, Confluent 등). CRDT가 분산 환경에 더 적합.
8. CRDT의 한계와 함정
8.1 메타데이터 폭증
문제: tombstone, 태그, 클럭 등이 누적되어 문서 크기가 실제 콘텐츠보다 큼.
해결:
- 압축(Compaction): 더 이상 필요 없는 메타데이터 정리
- 델타 압축: Yjs가 사용. 연속 작업을 단일 객체로
- 체크포인트: 주기적으로 베이스라인 생성
8.2 의미적 충돌
CRDT는 구문적 충돌(같은 위치 동시 편집)을 해결합니다. 의미적 충돌은 해결 못 합니다.
예시: 캘린더 앱. 두 사용자가 같은 회의실을 다른 회의로 동시 예약. CRDT는 둘 다 예약 성공으로 처리 — 비즈니스 규칙 위반.
해결: CRDT 위에 비즈니스 로직 레이어 추가. 또는 강한 일관성이 필요한 부분은 다른 메커니즘.
8.3 부분적 CRDT가 더 실용적
Notion의 접근: 블록 수준에서 CRDT, 블록 내부는 일반 텍스트. 모든 것을 CRDT로 만들 필요 없음.
Linear의 접근: 일부 필드만 CRDT, 나머지는 LWW. 단순함과 성능의 균형.
9. 실전 — 협업 텍스트 에디터 만들기
9.1 기본 구조
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { EditorView, basicSetup } from 'codemirror'
import { yCollab } from 'y-codemirror.next'
// 1. Yjs 문서 생성
const ydoc = new Y.Doc()
const ytext = ydoc.getText('codemirror')
// 2. 동기화 프로바이더
const provider = new WebsocketProvider(
'wss://my-server.com',
'document-id-123',
ydoc
)
// 3. CodeMirror 에디터 + Yjs 통합
const view = new EditorView({
doc: ytext.toString(),
extensions: [
basicSetup,
yCollab(ytext, provider.awareness) // 협업 확장
],
parent: document.body
})
코드 100줄 미만으로 Google Docs 같은 협업 에디터 구현 가능.
9.2 사용자 인식 (Awareness)
provider.awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff0000'
})
provider.awareness.on('change', () => {
const users = Array.from(provider.awareness.getStates().values())
console.log('Online users:', users)
})
다른 사용자의 커서, 선택 영역이 자동으로 표시됩니다.
퀴즈
1. CRDT의 수학적 속성 3가지는?
답: (1) 결합법칙 (Associativity): (a + b) + c = a + (b + c), (2) 교환법칙 (Commutativity): a + b = b + a, (3) 멱등성 (Idempotence): a + a = a. 이 세 가지를 만족하면 어떤 순서로 병합해도 결과가 동일합니다. 이를 ACI 속성 또는 semilattice라고 합니다. 이것이 CRDT의 자동 충돌 해결의 수학적 기반입니다.
2. State-based와 Operation-based CRDT의 차이는?
답: **State-based (CvRDT)**는 전체 상태를 전송, 병합 함수로 결합. 메시지 순서 무관, 단순. 단점은 큰 상태 = 큰 네트워크 비용. **Operation-based (CmRDT)**는 연산만 전송. 작은 메시지지만 안정적인 메시지 전달 필요 (at-least-once delivery + 멱등성). 실용적 라이브러리(Yjs, Automerge)는 하이브리드 접근을 사용합니다.
3. Yjs와 Automerge 중 무엇을 선택해야 하나요?
답: 텍스트 협업 (에디터, 노트, IDE) → Yjs (성능과 메모리 효율 우수, 사용처: Notion, Linear, Affine). JSON 데이터 동기화 (앱 상태, 폼, 설정) → Automerge (JSON-like API, 학습 곡선 낮음). 둘 다 고려된다면 프로토타입은 Automerge로 빠르게, 프로덕션은 Yjs로 최적화하는 패턴이 일반적입니다.
4. Local-First Software의 핵심 가치는?
답: Ink & Switch의 7가지 이상: (1) 빠르다(네트워크 의존 X), (2) 다중 디바이스, (3) 네트워크 선택적(오프라인 완전 작동), (4) 협업, (5) 장기적 보존(클라우드 사라져도 데이터 유지), (6) 보안/프라이버시(데이터는 디바이스에), (7) 사용자 데이터 통제. 클라우드 우선은 회사가 망하면 데이터도 사라지지만, 로컬 우선은 데이터가 사용자 손에 있습니다. Linear, Figma, Notion이 채택했습니다.
5. CRDT가 해결할 수 없는 충돌은?
답: **의미적 충돌(semantic conflict)**입니다. CRDT는 같은 데이터 구조의 동시 변경을 자동 병합하지만, 비즈니스 규칙 위반은 못 막습니다. 예시: 두 사용자가 같은 회의실을 다른 회의로 동시 예약 → CRDT는 둘 다 성공으로 처리. 해결: CRDT 위에 비즈니스 로직 레이어 추가, 또는 강한 일관성이 필요한 부분은 별도 메커니즘(중앙 검증, 분산 락 등) 사용.
참고 자료
- CRDT.tech — CRDT 종합 자료
- Local-First Software — Ink & Switch 매니페스토
- Yjs — 공식 문서
- Automerge — 공식 문서
- Conflict-Free Replicated Data Types — 원논문 (Shapiro 2011)
- A Conflict-Free Replicated JSON Datatype — Automerge 논문
- YATA: Yet Another Transformation Approach — Yjs 알고리즘
- Designing Data-Intensive Applications — Martin Kleppmann (CRDT 챕터)
- Linear's offline mode — Linear 동기화 엔진
- Figma's multiplayer technology — Figma 협업
- Diamond Types — Rust로 작성된 고성능 CRDT
CRDT Complete Guide 2025: Conflict-Free Replicated Data Types, Local-First Collaboration, Yjs/Automerge
TL;DR
- The magic of CRDTs: multiple users can modify different replicas concurrently and automatically converge to the same state. No locks, no central server required
- Two approaches: State-based (send full state) vs Operation-based (send operations). Each with pros and cons
- Local-first movement: started by Ink & Switch research lab. Adopted by Figma, Linear, Notion
- Yjs vs Automerge: Yjs excels at performance and text collaboration, Automerge excels at JSON data models
- When to use: collaborative editors, offline-first apps, P2P sync, distributed memos
1. What is a CRDT?
1.1 The Problem — Conflicts in Distributed Environments
Scenario: Two users edit the same document simultaneously.
Initial: "Hello"
User A adds: "Hello World"
User B adds concurrently: "Hello Friend"
How to merge? Which one wins?
Traditional solutions:
- Last Write Wins — one person's change disappears
- Manual merge — user resolves directly
- Locking — only one can edit, collaboration impossible
CRDT's answer: Merge both changes automatically and meaningfully. Both are preserved.
1.2 Mathematical Definition of CRDTs
A CRDT is a data type with three properties:
- Associativity:
(a + b) + c = a + (b + c) - Commutativity:
a + b = b + a - Idempotence:
a + a = a
If all three hold, merging in any order yields the same result. This is called the ACI property or semilattice.
1.3 Two Approaches
State-based CRDT (CvRDT)
- Send the full state
- Merge function:
merge(a, b) -> c - Pros: simple, message order doesn't matter
- Cons: large state = high network cost
Operation-based CRDT (CmRDT)
- Send only operations
- All nodes must apply the same operations the same number of times
- Pros: small messages
- Cons: requires reliable message delivery (at-least-once)
Practical choice: most libraries (Yjs, Automerge) use a hybrid approach.
2. Basic CRDT Types
2.1 G-Counter (Grow-only Counter)
A counter that can only increment.
class GCounter:
def __init__(self, node_id):
self.node_id = node_id
self.counts = {} # {node_id: count}
def increment(self):
self.counts[self.node_id] = self.counts.get(self.node_id, 0) + 1
def value(self):
return sum(self.counts.values())
def merge(self, other):
# Merge each node's count with max
for node, count in other.counts.items():
self.counts[node] = max(self.counts.get(node, 0), count)
Why max?: Each node's count is monotonically increasing. Taking max automatically determines which node is more recent.
Use cases: page view counts, likes.
2.2 PN-Counter (Positive-Negative Counter)
Supports both increment and decrement.
class PNCounter:
def __init__(self, node_id):
self.positive = GCounter(node_id) # for increments
self.negative = GCounter(node_id) # for decrements
def increment(self):
self.positive.increment()
def decrement(self):
self.negative.increment()
def value(self):
return self.positive.value() - self.negative.value()
def merge(self, other):
self.positive.merge(other.positive)
self.negative.merge(other.negative)
Key trick: split into two G-Counters. Decrement is expressed as "incrementing the negative side."
2.3 G-Set (Grow-only Set)
A set that supports only additions.
class GSet:
def __init__(self):
self.elements = set()
def add(self, e):
self.elements.add(e)
def merge(self, other):
self.elements |= other.elements # union
Limitation: no removals. The set grows forever.
2.4 2P-Set (Two-Phase Set)
Supports both add and remove. However, once removed, an element cannot be re-added.
class TwoPhaseSet:
def __init__(self):
self.added = set()
self.removed = set()
def add(self, e):
if e not in self.removed:
self.added.add(e)
def remove(self, e):
self.removed.add(e)
def contains(self, e):
return e in self.added and e not in self.removed
def merge(self, other):
self.added |= other.added
self.removed |= other.removed
Limitation: tombstones (removal markers) accumulate forever.
2.5 LWW-Register (Last-Write-Wins Register)
A single value based on timestamps.
class LWWRegister:
def __init__(self):
self.value = None
self.timestamp = 0
def write(self, value, timestamp):
if timestamp > self.timestamp:
self.value = value
self.timestamp = timestamp
def merge(self, other):
if other.timestamp > self.timestamp:
self.value = other.value
self.timestamp = other.timestamp
Problem: clock synchronization. Solved with Hybrid Logical Clock (HLC).
2.6 OR-Set (Observed-Remove Set)
The most practical Set CRDT. Free add/remove.
class ORSet:
def __init__(self):
# {element: set of unique tags}
self.elements = defaultdict(set)
self.tombstones = defaultdict(set)
def add(self, e):
tag = uuid.uuid4()
self.elements[e].add(tag)
def remove(self, e):
# Move all currently visible tags to tombstones
self.tombstones[e] |= self.elements[e]
def contains(self, e):
return bool(self.elements[e] - self.tombstones[e])
def merge(self, other):
for e, tags in other.elements.items():
self.elements[e] |= tags
for e, tags in other.tombstones.items():
self.tombstones[e] |= tags
Core idea: each add gets a unique tag. Remove adds "currently observed tags" to tombstones. New adds get new tags, so they survive.
3. Text Collaboration — The Hardest CRDT
3.1 Why Is Text So Hard?
Scenario: two users insert at the same position simultaneously.
Initial: "ABCDE"
User A: insert "X" at position 2 -> "ABXCDE"
User B: insert "Y" at position 2 -> "ABYCDE"
Merge: "ABXYCDE" or "ABYXCDE"?
Problem: positions are relative. One user's insert invalidates another user's positions.
3.2 RGA (Replicated Growable Array)
Each character gets a unique ID. Reference IDs instead of positions.
Initial document:
[start] - A(id1) - B(id2) - C(id3) - [end]
User A: insert X after B (id4)
[start] - A(id1) - B(id2) - X(id4) - C(id3) - [end]
User B: insert Y after B (id5)
[start] - A(id1) - B(id2) - Y(id5) - C(id3) - [end]
Merge (compare by id, smaller id first):
[start] - A(id1) - B(id2) - X(id4) - Y(id5) - C(id3) - [end]
Key: unique IDs + ordering rules enable deterministic merging.
3.3 Yjs's YATA Algorithm
Yjs uses a variant algorithm called YATA (Yet Another Transformation Approach). More efficient than RGA.
Each character carries:
- Unique ID
(client_id, clock) origin_left: the ID of the left neighbor at insertion timeorigin_right: the ID of the right neighbor at insertion time
Merge rules:
- Inserts with the same origin are sorted by client_id
- When origins differ, sort by positional information
Efficiency: Yjs compresses consecutive typing of the same string into a single object, giving 100x+ memory efficiency.
4. Yjs — The Standard JavaScript CRDT
4.1 Basic Usage
import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'
// Create a shared document
const doc = new Y.Doc()
// Shared text
const ytext = doc.getText('shared-text')
ytext.insert(0, 'Hello, ')
ytext.insert(7, 'World!')
console.log(ytext.toString()) // "Hello, World!"
// P2P sync (WebRTC)
const provider = new WebrtcProvider('my-room', doc)
4.2 Various Data Types
// Text
const ytext = doc.getText('text')
ytext.insert(0, 'Hello')
// Array
const yarray = doc.getArray('list')
yarray.push(['item1', 'item2'])
// Map (object)
const ymap = doc.getMap('config')
ymap.set('theme', 'dark')
// XML/JSON
const yxml = doc.getXmlFragment('content')
4.3 Change Detection
ytext.observe((event) => {
console.log('Changes:', event.changes.delta)
// [{ retain: 7 }, { insert: 'World!' }]
})
4.4 Persistence — IndexedDB
import { IndexeddbPersistence } from 'y-indexeddb'
const persistence = new IndexeddbPersistence('my-doc', doc)
persistence.on('synced', () => {
console.log('Loaded from IndexedDB')
})
State is preserved across browser reloads — truly local-first.
4.5 Multiple Providers
Yjs is transport-agnostic and supports multiple sync mechanisms:
y-websocket— centralized server syncy-webrtc— P2P syncy-indexeddb— local persistencey-leveldb— Node.js server- Custom providers are easy to write
5. Automerge — JSON Data Model
5.1 JSON-Friendly Interface
import * as Automerge from '@automerge/automerge'
let doc = Automerge.init()
doc = Automerge.change(doc, 'Initial', d => {
d.todos = []
d.todos.push({ text: 'Buy milk', done: false })
})
// Another device
let doc2 = Automerge.merge(Automerge.init(), doc)
doc2 = Automerge.change(doc2, 'Add task', d => {
d.todos.push({ text: 'Walk dog', done: false })
})
// Merge
const merged = Automerge.merge(doc, doc2)
console.log(merged.todos) // [Buy milk, Walk dog]
5.2 Yjs vs Automerge
| Yjs | Automerge | |
|---|---|---|
| Language | JavaScript | TypeScript + WASM |
| Data model | CRDT types (Y.Text, Y.Map) | JSON-like |
| Text performance | Very fast | Good |
| Memory efficiency | Excellent | Moderate |
| Learning curve | Medium | Low |
| Use cases | Collaborative editors | General data sync |
| Document size | Small | Moderate |
| Used by | Notion, Linear, Affine | Local-first apps |
Selection guide:
- Text collaboration (editors, notes): Yjs
- JSON data (app state, forms): Automerge
- Both under consideration: prototype faster with Automerge
6. The Local-First Software Movement
6.1 Seven Ideals
The Local-First Software manifesto by Ink & Switch:
- Fast — no network round trips
- Multi-device — sync in the background
- Network-optional — fully works offline
- Collaborate with others — conflict resolution via CRDT
- Long-term preservation — data survives even if the cloud disappears
- Security and privacy by default — data stays on user devices
- Ultimate user control — data ownership
6.2 Cloud-First vs Local-First
| Cloud-First | Local-First | |
|---|---|---|
| Data location | Server | Device |
| Offline | Does not work | Fully works |
| Response time | Network-dependent | Instant |
| Collaboration | Server-mediated | P2P or server |
| Company shutdown | Data lost | Data retained |
| Examples | Google Docs | Obsidian, Logseq, Linear |
6.3 Local-First Adoption Cases
Linear — project management:
- Full data in local IndexedDB
- Instant response (latency 0)
- Background sync via WebSocket
- Offline changes queued, sent on reconnect
Figma — design collaboration:
- Custom CRDT implementation (RGA-based)
- Real-time multi-cursor
- Offline editing then sync
Affine — notes:
- Uses Yjs
- Fully local-first
- Cloud sync is optional
Notion — notes/wiki:
- Partial CRDT (per block)
- Migrating text editing from OT to CRDT
7. CRDT vs OT (Operational Transformation)
7.1 What is OT?
The traditional collaboration approach used by Google Docs. Operation transformation — transform concurrent operations to maintain consistency.
Initial: "ABC"
User A: insert(1, "X") -> "AXBC"
User B: delete(2) -> "AB"
Transform User B's op after User A's change:
delete(2) -> delete(3) (position adjusted)
Result: "AXB"
7.2 Comparison
| OT | CRDT | |
|---|---|---|
| Central server | Required | Optional |
| Offline support | Hard | Natural |
| Algorithmic complexity | Very complex | Complex (but verifiable) |
| P2P | Hard | Natural |
| Google Docs | Yes (current) | No |
| Figma, Linear | No | Yes |
| Academic research | since 1990s | since 2000s |
Trend: OT -> CRDT migration (Notion, Confluent, etc.). CRDTs fit distributed environments better.
8. CRDT Limitations and Pitfalls
8.1 Metadata Explosion
Problem: tombstones, tags, clocks, etc. accumulate so that document size exceeds the actual content.
Solutions:
- Compaction: clean up metadata no longer needed
- Delta compression: used by Yjs. Consecutive ops merged into a single object
- Checkpoints: periodic baselines
8.2 Semantic Conflicts
CRDTs resolve syntactic conflicts (concurrent edits at the same location). Semantic conflicts are not resolved.
Example: a calendar app. Two users simultaneously book the same meeting room for different meetings. CRDT treats both bookings as successful — a business-rule violation.
Solution: add a business-logic layer on top of the CRDT. Or, for parts needing strong consistency, use a different mechanism.
8.3 Partial CRDTs Are More Practical
Notion's approach: CRDT at the block level, normal text inside blocks. Not everything needs to be a CRDT.
Linear's approach: only certain fields are CRDTs, others use LWW. A balance of simplicity and performance.
9. Hands-On — Building a Collaborative Text Editor
9.1 Basic Structure
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { EditorView, basicSetup } from 'codemirror'
import { yCollab } from 'y-codemirror.next'
// 1. Create Yjs document
const ydoc = new Y.Doc()
const ytext = ydoc.getText('codemirror')
// 2. Sync provider
const provider = new WebsocketProvider(
'wss://my-server.com',
'document-id-123',
ydoc
)
// 3. CodeMirror editor + Yjs integration
const view = new EditorView({
doc: ytext.toString(),
extensions: [
basicSetup,
yCollab(ytext, provider.awareness) // collaboration extension
],
parent: document.body
})
In under 100 lines of code, you get a Google Docs-style collaborative editor.
9.2 User Awareness
provider.awareness.setLocalStateField('user', {
name: 'Alice',
color: '#ff0000'
})
provider.awareness.on('change', () => {
const users = Array.from(provider.awareness.getStates().values())
console.log('Online users:', users)
})
Other users' cursors and selections are displayed automatically.
Quiz
1. What are the three mathematical properties of a CRDT?
Answer: (1) Associativity: (a + b) + c = a + (b + c), (2) Commutativity: a + b = b + a, (3) Idempotence: a + a = a. If all three hold, merging in any order yields the same result. This is the ACI property or semilattice. It is the mathematical foundation of CRDTs' automatic conflict resolution.
2. What is the difference between State-based and Operation-based CRDTs?
Answer: State-based (CvRDT) transmits full state and combines via a merge function. Message order doesn't matter; simple. Downside: large state equals high network cost. Operation-based (CmRDT) transmits only operations. Smaller messages, but requires reliable delivery (at-least-once + idempotence). Practical libraries (Yjs, Automerge) use a hybrid approach.
3. Should you choose Yjs or Automerge?
Answer: Text collaboration (editors, notes, IDEs) -> Yjs (superior performance and memory efficiency; used by Notion, Linear, Affine). JSON data sync (app state, forms, settings) -> Automerge (JSON-like API, low learning curve). If both are on the table, a common pattern is to prototype quickly with Automerge and optimize for production with Yjs.
4. What are the core values of Local-First Software?
Answer: Ink & Switch's seven ideals: (1) fast (no network dependency), (2) multi-device, (3) network-optional (fully offline-capable), (4) collaboration, (5) long-term preservation (survives cloud shutdown), (6) security/privacy (data on device), (7) user control of data. Cloud-first: data disappears if the company shuts down. Local-first: data remains in user hands. Adopted by Linear, Figma, Notion.
5. What conflicts can CRDTs not resolve?
Answer: Semantic conflicts. CRDTs automatically merge concurrent changes to the same data structure, but cannot prevent business-rule violations. Example: two users book the same meeting room for different meetings at the same time -> CRDT treats both as successful. Solution: add a business-logic layer on top of the CRDT, or use a separate mechanism for parts needing strong consistency (central validation, distributed locks, etc.).
References
- CRDT.tech — comprehensive CRDT resources
- Local-First Software — Ink & Switch manifesto
- Yjs — official docs
- Automerge — official docs
- Conflict-Free Replicated Data Types — the original paper (Shapiro 2011)
- A Conflict-Free Replicated JSON Datatype — Automerge paper
- YATA: Yet Another Transformation Approach — Yjs algorithm
- Designing Data-Intensive Applications — Martin Kleppmann (CRDT chapter)
- Linear's offline mode — Linear sync engine
- Figma's multiplayer technology — Figma collaboration
- Diamond Types — high-performance CRDT written in Rust