loro Documentation

Make your JSON data collaborative and version-controlled with CRDTs

RAW Doc

Docker & Self-Hosted Deployment Verified OCI

Deploy loro on any server, homelab, or cloud container platform.

1-Click Terminal Command
bash (docker run)
docker run -d --name loro --restart always loro-dev/loro:latest

File: README.md

[Loro](https://loro.dev)

https://github.com/loro-dev/loro/assets/18425020/fe246c47-a120-44b3-91d4-1e7232a5b4ac

✨ Loro 1.0 is out! Read the [announcement](https://loro.dev/blog/v1.0).

Loro is a CRDTs(Conflict-free Replicated Data Types) library that makes building local-first and collaborative apps easier. You can now use it in Rust, JS (via WASM), and Swift.

Features

Features Provided by CRDTs

  • P2P Synchronization
  • Automatic Merging
  • Local Availability
  • Scalability
  • Delta Updates

Supported CRDT Algorithms

Advanced Features in Loro

https://github.com/user-attachments/assets/68e0017a-4987-4f71-b2cf-4ed28a210987

In this example, we demonstrate importing an entire Loro codebase into a Loro-powered
version controller, preserving the complete Git DAG history while enabling fast version switching.

Example

ts
import { expect, test } from "vitest";
import { LoroDoc, LoroList } from "loro-crdt";

test("sync example", () => {
  // Sync two docs with two rounds of exchanges

  // Initialize document A
  const docA = new LoroDoc();
  const listA: LoroList = docA.getList("list");
  listA.insert(0, "A");
  listA.insert(1, "B");
  listA.insert(2, "C");

  // Export all updates from docA
  const bytes: Uint8Array = docA.export({ mode: "update" });

  // Simulate sending `bytes` across the network to another peer, B

  const docB = new LoroDoc();
  // Peer B imports the updates from A
  docB.import(bytes);

  // B's state matches A's state
  expect(docB.toJSON()).toStrictEqual({
    list: ["A", "B", "C"],
  });

  // Get the current version of docB
  const version = docB.oplogVersion();

  // Simulate editing at B: delete item 'B'
  const listB: LoroList = docB.getList("list");
  listB.delete(1, 1);

  // Export the updates from B since the last sync point
  const bytesB: Uint8Array = docB.export({ mode: "update", from: version });

  // Simulate sending `bytesB` back across the network to A

  // A imports the updates from B
  docA.import(bytesB);

  // A has the same state as B
  expect(docA.toJSON()).toStrictEqual({
    list: ["A", "C"],
  });
});

DevTools

Loro Inspector

You can use the Loro Inspector to inspect the state and history of a Loro document.

https://github.com/user-attachments/assets/ceeb7450-80ce-42f2-aef4-2e08fa2d1f1b

Bindings

You can find bindings for other programming languages in loro-ffi.

Blog

Sponsors

Credits

Loro draws inspiration from the innovative work of the following projects and individuals:

  • Diamond-types: The Event Graph Walker (Eg-walker) algorithm from @josephg has been adapted to reduce the computation and space usage of CRDTs.
  • Automerge: Their use of columnar encoding for CRDTs has informed our strategies for efficient data encoding.
  • Yjs: We have incorporated a similar algorithm for effectively merging collaborative editing operations, thanks to their pioneering work.
  • Matthew Weidner: His work on the Fugue algorithm has been invaluable, enhancing our text editing capabilities.
  • Martin Kleppmann: His work on CRDTs has significantly influenced our comprehension of the field.

File: crates/loro-wasm/README.md

[Loro](https://loro.dev)

✨ Loro 1.0 is out! Read the [announcement](https://loro.dev/blog/v1.0).

Loro is a CRDTs(Conflict-free Replicated Data Types) library that makes building local-first and collaborative apps easier. You can now use it in Rust, JS (via WASM), and Swift.

Features

Features Provided by CRDTs

  • P2P Synchronization
  • Automatic Merging
  • Local Availability
  • Scalability
  • Delta Updates

Supported CRDT Algorithms

Advanced Features in Loro

In this example, we demonstrate importing an entire Loro codebase into a Loro-powered
version controller, preserving the complete Git DAG history while enabling fast version switching.

Debugging the Wasm build

The standard build pipeline (deno run -A ./scripts/build.ts dev|release) now keeps DWARF debugging information through wasm-bindgen and emits two helper files alongside every loro_wasm_bg.wasm artifact:

  • loro_wasm_bg.wasm.map — a v3 source map derived from DWARF so that Chrome, Edge, and Firefox can show original Rust locations when inspecting stack traces.

Load the source map in browser devtools; when devtools fetches the debug companion it can map instructions back to Rust source files and line numbers without inflating the shipped .wasm.

Bundler entries

Bare import { LoroDoc } from "loro-crdt" uses package conditional exports. Browser development builds can resolve the nested browser + development conditions to the bundler entry, while browser production builds can resolve the browser condition to a synchronous browser build that avoids Vite/Rolldown production chunk cycles around .wasm wrappers. A legacy browser string field also points to the browser entry for bundlers that still consult it. Runtimes that need native .wasm module imports can still use the bundler entry, and apps that prefer explicit async initialization can use loro-crdt/web.

Vite and Webpack understand new URL("./loro_wasm_bg.wasm", import.meta.url) and emit the WASM asset automatically. Plain esbuild and plain Rollup do not copy that asset by default. For those tools, either import loro-crdt/base64 to inline the WASM into the JS bundle without top-level await, or keep the default loro-crdt import and copy node_modules/loro-crdt/browser/loro_wasm_bg.wasm next to the emitted JS bundle as a build step.

Next.js Turbopack can use the default browser entry. If a Next.js Webpack build resolves the bundler entry instead of the package browser remap, use loro-crdt/base64.

Example

ts
import { expect, test } from 'vitest';
import { LoroDoc, LoroList } from 'loro-crdt';

test('sync example', () => {
  // Sync two docs with two rounds of exchanges

  // Initialize document A
  const docA = new LoroDoc();
  const listA: LoroList = docA.getList('list');
  listA.insert(0, 'A');
  listA.insert(1, 'B');
  listA.insert(2, 'C');

  // Export all updates from docA
  const bytes: Uint8Array = docA.export({ mode: 'update' });

  // Simulate sending `bytes` across the network to another peer, B

  const docB = new LoroDoc();
  // Peer B imports the updates from A
  docB.import(bytes);

  // B's state matches A's state
  expect(docB.toJSON()).toStrictEqual({
    list: ['A', 'B', 'C'],
  });

  // Get the current version of docB
  const version = docB.oplogVersion();

  // Simulate editing at B: delete item 'B'
  const listB: LoroList = docB.getList('list');
  listB.delete(1, 1);

  // Export the updates from B since the last sync point
  const bytesB: Uint8Array = docB.export({ mode: 'update', from: version });

  // Simulate sending `bytesB` back across the network to A

  // A imports the updates from B
  docA.import(bytesB);

  // A has the same state as B
  expect(docA.toJSON()).toStrictEqual({
    list: ['A', 'C'],
  });
});

Blog

Credits

Loro draws inspiration from the innovative work of the following projects and individuals:

  • Diamond-types: The Event Graph Walker (Eg-walker) algorithm from @josephg has been adapted to reduce the computation and space usage of CRDTs.
  • Automerge: Their use of columnar encoding for CRDTs has informed our strategies for efficient data encoding.
  • Yjs: We have incorporated a similar algorithm for effectively merging collaborative editing operations, thanks to their pioneering work.
  • Matthew Weidner: His work on the Fugue algorithm has been invaluable, enhancing our text editing capabilities.
  • Martin Kleppmann: His work on CRDTs has significantly influenced our comprehension of the field.

File: crates/loro-internal/docs/diff_calc.md

Internal Diff Calculation

Diff calculation produces the state patch between two versions. Checkout,
forking, import, and revert all use this path.

Replay base and changed containers

OpLog::iter_from_replay_base_causally first chooses a replay base. The safe
base is a critical version in the Eg-walker sense (arXiv:2409.14252 Β§3.5):
a version that no concurrency crosses. When a concurrent branch invalidates
the candidate, the DAG retreats the base to the latest single-head critical
version, which can be older than the meet of the two versions.

An old base is not evidence that every container in the replay range changed.
DiffCalculator::calc_diff_internal derives the changed container set from the
version-vector difference and routes common history only to those calculators.
Otherwise each unchanged List/Text/MovableList can trigger its own full-history
tracker rebuild.

The DAG walk follows explicit dependencies plus the implicit previous counter
of the same peer. An implicit path may be redundant when an explicit relay
dependency already contains that predecessor. The walk remembers the dependency
tip where an unmatched path split and performs a targeted ancestor lookup from
the candidate common frontiers:

  • a covered tip is another route into already-common history, so from remains
    the replay base;
  • an uncovered tip is a real concurrent branch, so the conservative base is
    retained.

This lookup runs only for unmatched branch tips and prunes by visited DAG nodes
and Lamport time. It does not calculate a causal version for every new peer and
does not compare each one with the complete from version vector.

Diff modes

  • Checkout is the general and slowest mode. It can move in either direction
    and may use ContainerHistoryCache.
  • Import requires to > from, but some imported operations may be concurrent
    with from.
  • ImportGreaterUpdates additionally guarantees that every imported operation
    is causally after from, so the replay base is from.
  • Linear additionally guarantees that imported operations are ordered, so
    diff calculation does not need to build CRDT trackers.

List, Text, and MovableList still rebuild their trackers from CRDT IDs when
retreating, when their source context is incomplete, or when shallow history
requires it. That fallback is a correctness requirement, not a replay-base
optimization.


File: crates/loro-internal/README.md

loro-internal

This is the internal implementation of Loro.
Its interface subject to change. You should not use it as dependency directly.
Consider use loro crate instead.


File: crates/loro-common/README.md

loro-common

This crate contains the common basic types for the Loro project.
It's shared by loro-internal and loro-preload.


File: crates/loro/README.md

Loro

Loro is a high‑performance CRDT framework for local‑first apps that keeps state consistent across devices and users, works offline and in real time, automatically merges conflicts, and enables undo/redo and time travel.

Loro is a high-performance CRDTs library offering Rust, JavaScript and Swift APIs.

Common Tasks & Examples

Getting Started

Real-time Collaboration

Rich Text Editing

Data Structures

Ephemeral State & Presence

  • Not currently provided in the Rust crate. Model presence in your app layer alongside CRDT updates (e.g., via your network transport). Cursors can be shared using get_cursor data if needed.

Version Control & History

Performance & Storage

Documentation

  • Start with the Rust API docs for LoroDoc (container management, versioning, import/export, events)
    That page hosts examples and details for most important methods you’ll use day-to-day.
  • Loro Website for more details and guides
  • Loro Examples for more examples and guides

Getting Started

Add to your Cargo.toml:

toml
[dependencies]
loro = "^1"

LoroDoc quick tour

Optional cargo features:

toml
[dependencies]
loro = { version = "^1", features = ["jsonpath"] }

Quick Examples

  1. Local edits, change events, and two-peer sync
rust
use loro::{LoroDoc, ExportMode};
use std::sync::Arc;

let a = LoroDoc::new();
let b = LoroDoc::new();

// Listen for container diffs on `a`
let _changes = a.subscribe_root(Arc::new(|e| {
    println!("changed containers: {}", e.events.len());
}));

a.get_text("text").insert(0, "Hello, Loro!").unwrap();
a.commit(); // events fire on commit/export/import/checkout

// Sync via export/import (send `updates` via your transport)
let updates = a.export(ExportMode::all_updates()).unwrap();
b.import(&updates).unwrap();

assert_eq!(a.get_deep_value(), b.get_deep_value());
  1. Time travel and revert
rust
use loro::LoroDoc;

let doc = LoroDoc::new();
let text = doc.get_text("text");
text.insert(0, "Hello").unwrap();
let v0 = doc.state_frontiers();

text.insert(5, ", world").unwrap();
assert_eq!(text.to_string(), "Hello, world");

// Time travel to v0 (read-only)
doc.checkout(&v0).unwrap();
assert_eq!(text.to_string(), "Hello");

// Return to latest and revert
doc.checkout_to_latest();
doc.revert_to(&v0).unwrap();
assert_eq!(text.to_string(), "Hello");

File: crates/fractional_index/README.md

Fractional Index

This crate is forked from drifting-in-space/fractional_index


File: docs/Glossary.md

Glossary

Name Meaning Note
DAG Directed Acyclic Graph The changes in Loro form a DAG. The relationship is expressed by deps field.
RLE Run Length Encoding We not only use it on the encoding, but also in the vec and tree.
Change A merged batch of ops
Frontiers The DAG nodes that no one has dependencies on them They can be represented by a series of id
Op Operation
State (of a Container) The In the code, the state most refers to the visible state to users. For example, the text string of a text container, the key value pairs in the map container
Effect (of an Op) How the op affect the state We use CRDT to calculate the effect (obviously)
Tracker A special data structure to calculate the effects
Container A unit of CRDT data structure
Content Content of an Op
ID A global unit id for an Op It has the structure of {clientId, counter}
Counter The second field inside ID
Lamport Lamport timestamp
Span A series of continuous things
Causal Order The order of happen-before relationship The DAG expresses the causal order of the changes
VV Version Vector In code, it has exclusive end, VV {0: 8} means the last op id of client 0 is 0-7

RLE

We not only use RLE on the encoding, but also in the vec and tree.

i.e. the elements can be merged, sliced and have lengths. We call the element with length of 1 as atom element (cannot be sliced).

We use a Rle trait to express it. So Op, Change, elements inside RleTree all implement Rle.
This gives us a compact way to represent the mergeable elements.

Container

Each op is associated with one container. Different CRDT algorithms use different types of containers. There are hierarchical relationship between containers, but they cannot affect each other

Convention

  • There are methods like id_last, id_end, lamport_last, lamport_end. last refers to the last one, but
    end refers to the exclusive end of a range. For example, OpSpan ranging from 0-0 till 0-8 has id_last of 0-8
    and id_end of 0-9

File: docs/JsonSchema.md

JSON Schema for Loro's OpLog

Introduction

Loro supports multiple data structures and introduces many new concepts. Having only binary export formats would make it difficult for developers to understand the underlying processes. Better transparency leads to better developer experience. A human-readable JSON representation enables users to better understand and operate the document and to develop related tools.

To better understand this document, you may first need to understand how Loro stores historical editing data:

It should be noted that considering the usage scenario, JSON Schema only supports backward compatibility but not forward compatibility.

Specification

Root object

The root object contains all Changes, Ops, and critical metadata like start/end versions and schema version.

We will also extract the 64-bit integer PeerID to the beginning of the document and replace it internally with incrementing numbers starting from zero: 0, 1, 2, 3... This significantly reduces the document size and enhances readability.

ts
{
    "schema_version": number,
    "start_version": Map<string, number>,
    "peers": string[],
    "changes": Change[],
}
  • schema_version: the version of the schema that the document is encoded with. It's 1 for the current specification.
  • start_version: the start Frontiers version of the document. They are represented as a map from the decimal string representation of PeerID to Counter.
  • peers: the list of peers in the document. We represent all PeerIDs as decimal strings to avoid exceeding JavaScript's number limit.
  • changes: the list of changes in the document.

Changes

Changes are crucial in the OpLog. A REG(Replay event graph) is a directed acyclic graph where each node is a Change, and each edge is a causal dependency between Changes. The metadata of the Changes helps us reconstruct the graph.

You can also attach a commit message to a Change like you usually do with Git's commit.

ts
{
    "id": string,
    "timestamp": number,
    "deps": OpID[],
    "lamport": number,
    "msg": string,
    "ops": Op[]
}

type OpID = `${number}@${PeerID}`;
  • id: the string representation of the unique ID of each Change, in the form of {Counter}@{PeerID} which is the @ character connecting Counter and PeerID. Of course, This PeerID is the index of peers in the global context.
  • timestamp: the number of Unix timestamp when the change is committed. Timestamp is not recorded by default
  • deps: a list of causal dependency of this Change, each item is the ID represented by a string.
  • lamport: the lamport timestamp of the Change.
  • msg: the commit message.
  • ops: all of the Op in the Change.

Operations

Operation (abbreviated as Op) is the most complex part of the document. Loro currently supports multiple containers List, Map, RichText, Movable List and Movable Tree. Each data structure has several different Ops.

But in general, each Op is composed of the ContainerID of the container that created it, a counter, and the corresponding content of the Op.

ts
type Op = {
    "container": ContainerID,
    "counter": number,
    "content": OpContent // Its detailed definition is elaborated below, with different types for different Containers.
};

type OpContent = ListOp | TextOp | MapOp | TreeOp | MovableListOp | UnknownOp;
type ContainerID =
  | `cid:root-${string}:${ContainerType}`
  | `cid:${number}@${PeerID}:${ContainerType}`;

Mergeable child containers use reserved synthetic root-name container IDs under
the internal mergeable namespace, plus binary markers in parent map slots to
control visibility. They still fit the cid:root-${string}:${ContainerType}
shape in exported JSON. See
mergeable-container-id.md
for the reserved namespace and marker details.

  • container: the ContainerID of the container that created this Op, represented by a string starts with cid:.
  • counter: the counter part of the OpID
  • content: the semantic content of the Op, it is different for each field depending on the Container.

The following is the content of each container。

List

ts
type ListOp = ListInsertOp | ListDeleteOp;

Insert

ts
type ListInsertOp = {
    "type": "insert",
    "pos": number,
    "value": LoroValue
}
  • type: insert.
  • pos: the index of the insert operation.
  • value: the insert content which is a list of LoroValue

Delete

ts
type ListDeleteOp = {
    "type": "delete",
    "pos": number,
    "len": number,
    "start_id": OpID
}
  • type: delete.
  • pos: the start index of the deletion.
  • len: the length of deleted content.
  • start_id: the string id of start element deleted.

MovableList

ts
type MovableListOp = ListInsertOp | ListDeleteOp | MovableListMoveOp | MovableListSetOp;

Insert

ts
type ListInsertOp = {
    "type": "insert",
    "pos": number,
    "value": LoroValue
}
  • type: insert,
  • pos: the index of the insert operation.
  • value: the insert content which is a list of LoroValue

Delete

ts
type ListDeleteOp = {
    "type": "delete",
    "pos": number,
    "len": number,
    "start_id": OpID
}
  • type: delete
  • pos: the start index of the deletion.
  • len: the length of deleted content.
  • start_id: the string id of start element deleted.

Move

ts
type MovableListMoveOp = {
    "type": "move",
    "from": number,
    "to": number,
    "elem_id": ElemID
}

type ElemID = `L${number}@${PeerID}`
  • type:insert, delete, move or set.
  • from: the index of the element before is moved.
  • to: the index of the index moved to after moving out the element
  • elem_id: the ID (described by lamport@peer) of the element moved.

Set

ts
type MovableListSetOp = {
    "type": "set",
    "elem_id": ElemID,
    "value": LoroValue
}

type ElemID = `L${number}@${PeerID}`
  • type:insert, delete, move or set.
  • elem_id: the ID (described by lamport@peer) of the element replaced.
  • value: the value set.

Map

ts
type MapOp = MapInsertOp | MapDeleteOp;

Insert

ts
type MapInsertOp = {
    "type": "insert",
    "key": string,
    "value": LoroValue
}
  • type: insert.
  • key: the key of the insertion.
  • value: the value of the insertion.

Delete

ts
type MapDeleteOp = {
    "type": "delete",
    "key": string
}
  • type: delete.
  • key: the key of the deletion

Text

ts
type TextOp = TextInsertOp | TextDeleteOp | TextMarkOp | TextMarkEndOp;

Insert

ts
type TextInsertOp = {
    "type": "insert",
    "pos": number,
    "text": string
}

type: insert.
pos: the index of the insert operation. The position is based on the Unicode code point length.
text: the string of the insertion.

Delete

ts
type TextDeleteOp = {
    "type": "delete",
    "pos": number,
    "len": number,
    "start_id": OpID
}

type: delete.
pos: the index of the deletion. The position is based on the Unicode code point length.
len: the length of the text deleted.
start_id: the string id of the beginning element deleted.

Mark

ts
type TextMarkOp = {
    "type": "mark",
    "start": number,
    "end": number,
    "style_key": string,
    "style_value": LoroValue,
    "info": number
}

type: mark
start: the start index of text need to mark. The position is based on the Unicode code point length.
end: the end index of text need to mark. The position is based on the Unicode code point length.
style_key: the key of style, it is customizable.
style_value: the value of style, it is customizable.
info: the config of the style, whether to expand the style when inserting new text around it.

MarkEnd

ts
type TextMarkEndOp = {
    "type": "mark_end"
}

type: mark_end.

Tree

ts
type TreeOp = TreeCreateOp | TreeMoveOp | TreeDeleteOp;

Create

ts
type TreeCreateOp = {
    "type": "create",
    "target": TreeID,
    "parent": TreeID | null,
    "fractional_index": string
}

type TreeID = `${number}@${PeerID}`
  • type: create.
  • target: the string format of target TreeID moved.
  • parent: the string format of TreeID or null. If it is null, the target node will be a root node.
  • fractional_index: the fractional index with hex string format of the target node.

Move

ts
type TreeMoveOp = {
    "type": "move",
    "target": TreeID,
    "parent": TreeID | null,
    "fractional_index": string
}

type TreeID = `${number}@${PeerID}`
  • type: move.
  • target: the string format of target TreeID moved.
  • parent: the string format of TreeID or null. If it is null, the target node will be a root node.
  • fractional_index: the fractional index with hex string format of the target node.

Delete

ts
type TreeDeleteOp = {
    "type": "delete",
    "target": TreeID
}

type TreeID = `${number}@${PeerID}`
  • type: delete.
  • target: the string format of target TreeID deleted.

Unknown

To support forward compatibility, we have an unknown type. When an Op with a newly supported Container from a newer version is decoded into the older version, it will be treated as an unknown type in a more general form, such as binary and string. When the new version decodes an unknown Op, the newer version of Loro will know its true type and decode correctly.

ts
type UnknownOp = {
    "type": "unknown",
    "prop": number,
    "value_type": string,
    "value": `${EncodeValue}`
}
  • type: just an unknown type.
  • prop: a property of the encoded op, it's a number.
  • value_type: the type of EncodeValue.
  • value: common data types used in encoding with json string format.

Value

In this section, we will introduction two Value in Loro. One is LoroValue, it's an enum of data types supported by Loro, such as the value inserted by List or Map.

The another is EncodedValue, it's just used in encoding module for unknown type.

LoroValue

These are data types supported by Loro and its json format:

  • null: null
  • Bool: true or false
  • F64: number(float)
  • I64: number or bigint (signed)
  • Binary: UInt8Array
  • String: string
  • List: Array<LoroValue>
  • Map: Map<string, LoroValue>
  • Container: the id of container. 🦜:cid:{Counter}@{PeerID}:{ContainerType} or 🦜:cid:root-{Name}:{ContainerType}

Note: Compared with the string format, we add a prefix 🦜: when encoding the json format of ContainerID to prevent users from saving the string format of ContainerID and misinterpreting it as ContainerID when decoding.

EncodedValue

The EncodedValue is the specific type used by Loro when encoding, it's an internal value, users do not need to get it clear. It is specially designed to handle the schema mismatch due to forward and backward compatibility. In JSON encoding schema, the EncodedValue will be encoded as an object.