[type | entity_len | entity | row_id_len | row_id | payload]. The receiving side feeds the payload to a per-row LoroDoc that converges automatically.
What you get
- Collaborative text: multiple cursors, conflict-free inserts/deletes, undo/redo
- Collaborative lists: append, insert, move, delete with stable item ids
- Collaborative maps: key-value with last-writer-wins per key
- Collaborative trees: hierarchical structures (outlines, file trees)
- Counter: multi-writer increment/decrement that always converges
- Cursor positions: anchor a cursor to a position that survives concurrent edits
Install
PylonSync, with no separate install.
Declare CRDT-backed fields
In your schema:.crdt(annotation) on a field builder. "text" and "counter" are wired end-to-end today, and so is "prosemirror" for editor documents (see ProseMirror and Tiptap documents). "list", "movable-list", and "tree" are reserved (the wire format is locked in, the server-side projection is in progress). You can still edit list, map, and tree containers on the per-row LoroDoc (see below), because the server never interprets CRDT payloads.
The Pylon server stores CRDT fields as opaque bytes and does not interpret them. Clients decode the bytes into Loro containers and edit them locally. Updates broadcast to other subscribers via the binary WebSocket channel.
Subscribe and edit
useLoroDoc subscribes the engine to binary updates for (entity, rowId), decodes incoming frames, and returns the live LoroDoc. Edits to the doc dispatch through Loro’s local state; the hook ships the resulting binary update back over the WebSocket.
Multiple useLoroDoc callers on the same row share one subscription (refcounted). Opening the same document in two tabs doesn’t double-subscribe.
How edits reach the server
Every edit committed on auseLoroDoc doc goes to the server, whatever made it: an editor binding, useCollabText, or your own code. Each row has an outbox that sends this tab’s edits with POST /api/crdt/<entity>/<id>:
- One request at a time, so edits arrive in order. Edits made while a request is in flight go out together in the next one.
- Each request carries every edit of this tab the server has not confirmed, so a failed request loses nothing.
- A network error, a 5xx, a 408 or a 429 retries with backoff (500 ms, doubling to 10 s), and at once when the browser comes back online. Any other 4xx stops the outbox, because the same edits would fail the same way: for example the person lost edit access (403).
- When the page is hidden for the last time, unsent edits go out with
keepalive.
useLoroSendState(entity, id) returns "idle", "sending", "retrying" or "rejected", for a saving indicator or a notice that edits did not save:
Container types
Text — collaborative rich text
List — append / insert / move
Map — key-value
Counter — multi-writer increment
Tree — hierarchical
Cursors
For “show where each user is editing”:engine.setPresence({ cursor: myCursor }), you get the multiplayer-cursor effect.
ProseMirror and Tiptap documents
A field declared with.crdt("prosemirror") holds a ProseMirror document (Tiptap is built on ProseMirror). Editors merge it character by character, and the row reads it as ProseMirror JSON.
attributes map and children list, with no content). Clients never create its containers, they only edit inside the server’s: two editors that each created them would keep only one copy, and the other’s edits would be lost. Declare the field .optional() unless every insert carries a document: an empty document reads as null.
Insert a document with one empty block when several people may open it at once, so every editor starts from the same block:
Bind an editor
useProseMirrorDoc from @pylonsync/loro/prosemirror/react returns the row’s doc with the editor’s marks set up, and the field’s container once the server’s snapshot has brought it. ProseMirrorSyncPlugin and ProseMirrorUndoPlugin from @pylonsync/loro/prosemirror/sync bind the editor to it:
containerId is null until the snapshot arrives. Mount the editor after that. Outside React, configureProseMirrorMarks, findProseMirrorContainer and waitForProseMirrorContainer from @pylonsync/loro/prosemirror do the same steps.
ProseMirrorSyncPlugin replaces loro-prosemirror’s LoroSyncPlugin and keeps its plugin state, so loro-prosemirror’s undo and cursor plugins work with it. It applies a change from someone else as a replace of the range that changed, and keeps this person’s caret on the character it follows, so two people typing at one spot keep their words whole. LoroSyncPlugin replaced the whole document on every remote change and put the caret back on a timer: text typed before the timer ran went to the end of the document.
ProseMirrorUndoPlugin is loro-prosemirror’s LoroUndoPlugin with typing grouped into steps of 500 ms. Undo takes back only this person’s edits. While someone else types in the same paragraph, each undo step is smaller: Loro does not merge an undo step across another person’s change to the same text.
With Tiptap, add the plugins through an extension’s addProseMirrorPlugins, and turn off Tiptap’s own undo history (StarterKit.configure({ undoRedo: false })).
configureProseMirrorMarks (which useProseMirrorDoc calls) tells this copy of the document how each mark behaves at its edges: a mark that is inclusive (the ProseMirror default) extends to text typed right after it. Every copy that writes marks needs it; Loro refuses a mark it has no style for. The rule is recorded with each mark, so it holds for everyone who edits after.
Edit from a server function
An action edits a row’s live document withctx.crdt. openLiveDoc from @pylonsync/loro/server opens it as a LoroDoc, and editProseMirror changes a ProseMirror field the way an editor does: everyone editing sees the change as it lands, and their edits made at the same moment merge with it.
openLiveDocthrowsNOT_FOUNDwhen the row has no document.live.edit(fn)pulls in what others wrote since the doc was opened, runsfnon theLoroDoc, and sends only this function’s ops.refresh()andcommit()do the two halves on their own: a long run (an AI draft that streams in) commits as it goes and refreshes before each change.- Each
openLiveDocis a new Loro peer, so two functions editing one row merge like two people. ctx.crdtruns in actions only. Each commit merges and reaches everyone at once, so it cannot roll back with a mutation; a query or mutation getsCRDT_ACTIONS_ONLY.- Policies follow the
ctx.dbrule. Under strict function policies (PYLON_STRICT_FN_POLICIES=1) the caller’s read policy gatesopenLiveDoc(a row they may not read isNOT_FOUND) and their update policy gates each commit (POLICY_DENIED).openLiveDoc(ctx, entity, id, { unsafe: true })runs with server trust, likectx.db.unsafe. Without strict policies, check access in the function.
ctx.crdt itself takes and returns Loro bytes: snapshot(entity, id), since(entity, id, version) and apply(entity, id, update), for code that manages its own LoroDoc.
Read and write a document you hold
readProseMirror and writeProseMirror work on any LoroDoc that holds the field, such as a live doc’s live.doc:
writeProseMirror throws when the doc has no container for the field; it never creates one.
ctx.db.update("Doc", id, { body: json }) with ProseMirror JSON replaces the whole document instead, like writing a text field. An editor that is open keeps its binding, but edits made at the same moment are lost. Use it to reset a document, not to edit one. null empties the document and keeps its container.
The server checks the whole value before it writes anything, and a value that fails leaves the document as it was. Every node needs a string type; attrs is an object, content an array of nodes; a text node needs a string text; each mark needs a type and object attrs. A document may nest at most 64 levels and hold at most 200,000 nodes and 5,000,000 characters. A document a client built deeper than 64 levels projects only to that depth.
The server has no editor schema, so every mark it writes extends to text typed right after it, as an inclusive mark does. To write marks that must not extend (a link, a suggestion), write from a server function with writeProseMirror and the editor schema.
What the row holds
The row holds the document as ProseMirror JSON:{ type, attrs?, content? }, with text nodes as { type: "text", text, marks?: [{ type, attrs? }] }. Attributes that are null are left out, and marks are in type order. A row without a document reads as null.
One mark of a type per character
Each mark type is a Loro text style, which holds one value per character. Two marks of the same type cannot cover the same characters: two comment threads on the same words would leave only one. Keep anchors that can overlap, such as comment threads, as cursors stored next to the document, and draw them as decorations.Comment anchors and other ranges
@pylonsync/loro/prosemirror/ranges holds a range as two Loro cursors and draws it as an inline decoration. Ranges may overlap. The start binds to the range’s first character and the end to its last: a range stays on its words while anyone edits around it, grows when someone types inside it, and leaves out text typed just before or after it.
onReanchor with new cursors. Store them in place of the old ones. Other editors draw a range only where its cursors find it: after someone else splits or joins its paragraph, a range is hidden until the app sets the new cursors.
A range whose text was all deleted is not drawn, even when the same words appear elsewhere. When this editor deleted it but the binding kept its characters (deleting the first of two equal words keeps the first word’s characters), onReanchor gets null cursors: store them, so other editors stop drawing it. A cursor that cannot be read resolves as gone, not as an error. Each decoration has the class pylon-anchored-range unless you give className, and the range’s id in its spec as rangeId.
A server function has no editor. resolveRangesInDoc(doc, field, schema, ranges) reads the field’s document from the doc and resolves each range in it. The positions match the document readProseMirror returns and the state editProseMirror gives its change, so a function can keep an edit inside a range:
Awareness / presence
For lightweight ephemeral state (who’s editing, where their cursor is, what they’re typing) that doesn’t need to be persisted, use theuseRoom hook from @pylonsync/react. It returns the live peer list plus a setPresence you call as the local cursor moves:
peer.presence is the last object that peer pushed via setPresence. Presence rides the same WebSocket as CRDT updates but doesn’t go into the CRDT itself. It’s transient. Peers who disconnect drop out of room.peers.
For carets in a ProseMirror editor, ProseMirrorCursorsPlugin from @pylonsync/loro/prosemirror/cursors sends this person’s selection as Loro cursors (onSelection) and draws everyone else’s (setRemoteCursors). A cursor points at a character, so a caret stays on its words while anyone edits before it:
useRoom({ entity, id }) is the room tied to that row: only people who may read the row join it, see who is there, or receive its messages, and someone who loses access is removed (see Presence + rooms). Use it for a document’s cursors; a free room name like doc:${docId} lets any signed-in user in.
Saving and loading
The engine handles persistence for you. Every CRDT update is broadcast, and the server holds the canonical state. To get the current snapshot for a one-off use (export, share, backup):Undo / redo
Loro has built-in version vectors that make undo/redo work correctly even with concurrent edits:Performance
- Local edits are O(log n) in the size of the document. They stay fast even for large texts.
- Binary frames are tiny. Typical updates are a few dozen bytes. Snapshots are larger but only sent on subscribe.
- CRDT logic is Rust. Both
loro-crdt(JS) andloro-swiftwrap the same Rust core, so convergence is identical and performance is consistent. - No conflict markers ever appear. Concurrent edits always merge cleanly.
Wire format
Frame layout (matchescrates/router/src/lib.rs::encode_crdt_frame and packages/loro/src/wire.ts):
0x10 = full snapshot, 0x11 = incremental update.
The engine does not read payload. That is Loro’s binary format. To decode it for debugging:
Swift
Same model. The Swift bridge:PylonLoroDoc.attach(to:) registers the binary handler with the engine and sends the crdt-subscribe message. Detach (or let the doc go out of scope) to unsubscribe.
When to skip CRDTs
CRDTs work well for collaborative state, where two users might edit at the same time. They are unnecessary for:- Single-user data: use a normal entity field
- Server-authoritative state: orders, payments, anything where the server is the source of truth and last-write-wins is correct
- High-frequency telemetry: CRDTs aren’t designed for write-heavy event streams; use a regular entity with append semantics
Document.title can be a normal string while Document.body is a LoroText.
Production checklist
- Snapshot frequency: Loro’s hybrid logical clock works well. For very long-lived docs, periodically save a full snapshot to bound the size of the update history. The engine does this automatically.
- Garbage collection: Loro tracks tombstones for deletes.
doc.compact()reclaims memory after large deletions. - Presence cleanup: set a TTL on presence entries. Otherwise, users who close the tab without leaving will linger.
Examples
examples/pad— CRDT-backed collaborative markdown editorexamples/forge— design-tool-style multi-cursor editingexamples/linear— issue tracker with CRDT-backed comment threads