TextRope - Collaborative Text CRDT
TextRope is a high-performance text CRDT (Conflict-free Replicated Data Type) for real-time collaborative text editing. It's based on the 5000x faster CRDTs approach by Joseph Gentle, using the YATA ordering algorithm.
See Operations for text operation types that work with TextRope.
Features
- YATA ordering - Consistent ordering for concurrent edits with reduced interleaving
- Run-length encoding - Multi-character spans for memory efficiency
- Span splitting - Handles mid-span insertions correctly
- B-tree backed - O(log n) insertions and position lookups via a balanced tree
- Compact snapshots - Remove tombstones for efficient storage
Quick Example
Text operations follow the same pattern as other operations in the graph:
Text Operations
| Operation | Description | Example |
|---|---|---|
text.init | Initialize text property | { ot: 'text.init', key: 'doc-1', path: 'content', value: 'Hello' } |
text.insert | Insert at position | { ot: 'text.insert', key: 'doc-1', path: 'content', position: 5, value: [null, ' World'] } |
text.delete | Delete at position | { ot: 'text.delete', key: 'doc-1', path: 'content', position: 0, length: 6 } |
text.replace | Atomic delete + insert | { ot: 'text.replace', key: 'doc-1', path: 'content', position: 0, length: 5, value: [null, 'Hi'] } |
Important: Use text.replace instead of separate text.delete + text.insert when replacing a selection. The edit buffer deduplicates by key:path, so a delete followed by an insert on the same key and path will lose the delete. text.replace performs both atomically in a single operation.
Multi-User Collaboration
When multiple users edit concurrently, the CRDT ensures convergence:
Accessing the TextRope
Standalone Usage
Use the functional API directly without the graph:
Operations are plain data objects. The edit journal vs committed journal distinction happens at a higher level.
TextRope Data Structure
TextRope is a class instance backed by a B-tree for O(log n) operations:
Snapshots
Use compact() for storage when you don't need undo history.
Raw Format (Minimal Storage)
For maximum storage efficiency, use toRaw() and fromRaw():
The raw format removes all tombstones and uses positional arrays instead of objects for minimal size.
Design Philosophy
TextRope uses a class + standalone functions pattern:
- Class instance:
TextRopeis a class with internal B-tree and agent index — this ensures immutable state libraries (like Immer) skip it rather than wrapping its internals in proxies - Standalone functions: All operations (
insert,remove,getText, etc.) are pure functions that take the rope as the first argument - Factory function:
create('agent-id')returns a newTextRopeinstance - Serializable via snapshots: Use
snapshot(rope)/fromSnapshot(snap)ortoRaw(rope)/fromRaw(raw)for storage
This design allows:
- O(log n) insertions and lookups via the B-tree backing
- Safe deep-cloning via
snapshot()(important for state management — prevents mutation of previous states) - Works with any framework (React, Vue, etc.)
- Agent-seq index for O(log m) ID lookups without invalidation
API Reference
Functions
| Function | Description |
|---|---|
create(agentId) | Create a new TextRope |
getText(rope) | Get visible text content |
getLength(rope) | Get character count |
insert(rope, position, content) | Insert text, returns InsertOp |
insertWithSplit(rope, position, content) | Insert with span splitting |
remove(rope, position, length) | Delete text, returns DeleteOp |
replace(rope, position, deleteLen, text) | Replace text atomically, returns ReplaceOp |
move(rope, from, length, to) | Move text, returns MoveOp |
apply(rope, op) | Apply an InsertOp |
applyDelete(rope, op) | Apply a DeleteOp |
applyReplace(rope, op) | Apply a ReplaceOp |
applyMove(rope, op) | Apply a MoveOp |
merge(rope, other) | Merge another rope |
snapshot(rope) | Full snapshot |
compact(rope) | Compacted snapshot (no tombstones) |
toRaw(rope) | Minimal array format for storage |
fromRaw(raw, agentId?) | Restore from raw format |
fromSnapshot(snap, agentId?) | Restore from snapshot |
getStats(rope) | Get statistics |
Types
How It Works
YATA Ordering Algorithm
YATA (Yet Another Transformation Approach) orders concurrent inserts:
- Lamport timestamp - Higher timestamp goes first
- Parent position - If timestamps equal, compare parent positions
- Agent ID - If still equal, use agent ID for deterministic ordering
This reduces interleaving of concurrent edits compared to pure RGA.
Run-Length Encoding
Consecutive characters from the same agent are stored in a single item:
Span Splitting
When inserting in the middle of a span, the span is split:
Cross-Agent Convergence
TextRope uses character-level parent IDs to ensure convergence even when span boundaries differ across agents. Each character within a multi-character span has a logical ID: { agent, seq + offset }.
When Agent A inserts mid-span, the InsertOp.parentId references the exact character position, not the span start. When Agent B receives this op, it splits its local span at the correct character boundary:
DeleteOp uses an ID-range format — each deletion specifies a start ID and length. This allows correct application regardless of how the receiving agent has split its spans:
Architecture
3-Component Design
vuer-rtc uses a 3-component architecture for text handling:
Storage in SceneGraph
TextRope is a class instance. Shallow cloning preserves references (Immer skips class instances). Standalone functions like insert(), apply(), and getText() operate on it. Use snapshot() to deep-clone a rope for safe state management.
Performance
TextRope is optimized for real-world editing workloads. We benchmark against diamond-types editing traces — real collaborative editing sessions captured from production use.
Benchmark Results
| Dataset | Patches | Time | Throughput |
|---|---|---|---|
| friendsforever | 4,288 | 5ms | 858K patches/s |
| sveltecomponent | 19,749 | 46ms | 429K patches/s |
| clownschool | 23,182 | 63ms | 368K patches/s |
| rustcode | 40,173 | 200ms | 201K patches/s |
All benchmarks replay real editing traces and verify the final text matches the expected output exactly.
Optimizations
TextRope uses several techniques to achieve high throughput:
-
Fast-path local operations — Local inserts skip the O(n) deduplication and parent search scans that are only needed for remote operations. This alone provides a 10-28x speedup over the naive path.
-
Cursor caching — Sequential edits (the common case in typing) reuse the last position lookup, converting O(n) position scanning to O(1).
-
Agent-seq index — A lazily-built per-agent sorted index enables O(log n) ID lookups for remote operation integration, replacing O(n) linear scans.
-
Batch delete application — Remote deletes are grouped by agent, ranges are merged, and applied in a single pass per agent — O(n + d log d) instead of O(d·n).
-
Run-length encoding — Consecutive characters from the same agent share a single item, reducing memory and scan overhead.
Complexity
| Operation | Sequential | Random | Remote |
|---|---|---|---|
insert / insertWithSplit | O(1) | O(n) | O(n + log m) |
remove | O(1) | O(n) | — |
apply (remote insert) | — | — | O(n + log m) |
applyDelete (remote delete) | — | — | O(n + d log d) |
getText | O(n) | O(n) | O(n) |
Where n = total items, m = items per agent, d = deletions in the batch.
Notes
- Tombstones — Deleted items remain for CRDT consistency. Use
compact()for storage. - Network efficiency — Send
InsertOp/DeleteOpover the network, not full snapshots. - Scalability — Suitable for documents up to ~100K characters with real-time responsiveness.
Try it live: See TextRope in action in the Rope Demo.