Skip to content

Split View: CRDT 완전 가이드 2025: 충돌 없는 복제 데이터 타입, 로컬 우선 협업, Yjs/Automerge

✨ Learn with Quiz
|

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는 두 가지 속성을 가진 데이터 타입입니다:

  1. 결합법칙(Associativity): (a + b) + c = a + (b + c)
  2. 교환법칙(Commutativity): a + b = b + a
  3. 멱등성(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: 삽입 시 왼쪽 이웃의 ID
  • origin_right: 삽입 시 오른쪽 이웃의 ID

병합 규칙:

  1. 같은 origin을 가진 삽입들은 client_id 순으로 정렬
  2. 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

YjsAutomerge
작성 언어JavaScriptTypeScript + WASM
데이터 모델CRDT 타입 (Y.Text, Y.Map)JSON-like
텍스트 성능매우 빠름좋음
메모리 효율우수보통
학습 곡선중간낮음
사용 사례협업 에디터일반 데이터 동기화
문서 크기작음보통
사용처Notion, Linear, AffineLocal-first 앱

선택 가이드:

  • 텍스트 협업 (에디터, 노트): Yjs
  • JSON 데이터 (앱 상태, 폼): Automerge
  • 둘 다 고려: 프로토타입은 Automerge가 빠름

6. Local-First Software 운동

6.1 7가지 이상

Ink & Switch 연구소의 Local-First Software 매니페스토:

  1. 빠르다 — 네트워크 왕복 없음
  2. 다중 디바이스 작동 — 동기화는 백그라운드
  3. 네트워크 선택적 — 오프라인에서 완전 작동
  4. 다른 사람과 협업 — CRDT로 충돌 해결
  5. 장기적 보존 — 클라우드가 사라져도 데이터 보존
  6. 기본적 보안과 프라이버시 — 데이터는 사용자 디바이스에
  7. 궁극적 사용자 통제 — 데이터 소유권

6.2 클라우드 우선 vs 로컬 우선

클라우드 우선로컬 우선
데이터 위치서버디바이스
오프라인작동 X완전 작동
응답 시간네트워크 의존즉시
협업서버 중계P2P 또는 서버
회사 폐업데이터 손실데이터 유지
예시Google DocsObsidian, 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 비교

OTCRDT
중앙 서버필수선택
오프라인 지원어려움자연스러움
알고리즘 복잡도매우 복잡복잡 (하지만 검증 가능)
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 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:

  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 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 time
  • origin_right: the ID of the right neighbor at insertion time

Merge rules:

  1. Inserts with the same origin are sorted by client_id
  2. 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 sync
  • y-webrtc — P2P sync
  • y-indexeddb — local persistence
  • y-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

YjsAutomerge
LanguageJavaScriptTypeScript + WASM
Data modelCRDT types (Y.Text, Y.Map)JSON-like
Text performanceVery fastGood
Memory efficiencyExcellentModerate
Learning curveMediumLow
Use casesCollaborative editorsGeneral data sync
Document sizeSmallModerate
Used byNotion, Linear, AffineLocal-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:

  1. Fast — no network round trips
  2. Multi-device — sync in the background
  3. Network-optional — fully works offline
  4. Collaborate with others — conflict resolution via CRDT
  5. Long-term preservation — data survives even if the cloud disappears
  6. Security and privacy by default — data stays on user devices
  7. Ultimate user control — data ownership

6.2 Cloud-First vs Local-First

Cloud-FirstLocal-First
Data locationServerDevice
OfflineDoes not workFully works
Response timeNetwork-dependentInstant
CollaborationServer-mediatedP2P or server
Company shutdownData lostData retained
ExamplesGoogle DocsObsidian, 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

OTCRDT
Central serverRequiredOptional
Offline supportHardNatural
Algorithmic complexityVery complexComplex (but verifiable)
P2PHardNatural
Google DocsYes (current)No
Figma, LinearNoYes
Academic researchsince 1990ssince 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