Skip to main content
Pylon integrates with Loro for collaborative-editor features like Google Docs, Figma, and Linear-style multiplayer. Loro is a fast, tested CRDT library with bindings for JavaScript and Swift. CRDT-backed fields don’t go through normal LWW merge. They sync via a dedicated binary channel on the WebSocket. Every CRDT-mode write ships as [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
These CRDTs share the same wire format and the same convergence guarantees.

Install

The Swift SDK ships Loro bridging in PylonSync, with no separate install.

Declare CRDT-backed fields

In your schema:
Set CRDT container overrides with .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 a useLoroDoc 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

For rich text with marks (bold, italic, links):

List — append / insert / move

For lists where items keep stable ids across moves:

Map — key-value

Counter — multi-writer increment

Multiple clients incrementing concurrently are summed correctly — no last-writer-wins data loss.

Tree — hierarchical

Useful for outlines, file trees, organizational charts, mind maps.

Cursors

For “show where each user is editing”:
Combined with 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.
The server creates the field’s document when the row is inserted: the starting document you pass, or an empty one (its 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 with ctx.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.
  • openLiveDoc throws NOT_FOUND when the row has no document.
  • live.edit(fn) pulls in what others wrote since the doc was opened, runs fn on the LoroDoc, and sends only this function’s ops. refresh() and commit() 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 openLiveDoc is a new Loro peer, so two functions editing one row merge like two people.
  • ctx.crdt runs in actions only. Each commit merges and reaches everyone at once, so it cannot roll back with a mutation; a query or mutation gets CRDT_ACTIONS_ONLY.
  • Policies follow the ctx.db rule. Under strict function policies (PYLON_STRICT_FN_POLICIES=1) the caller’s read policy gates openLiveDoc (a row they may not read is NOT_FOUND) and their update policy gates each commit (POLICY_DENIED). openLiveDoc(ctx, entity, id, { unsafe: true }) runs with server trust, like ctx.db.unsafe. Without strict policies, check access in the function.
Each commit stores only the ops it added: the server appends them to the row’s update log and folds the log into a new snapshot after 100 entries or once it passes the snapshot’s size. A one-character edit to a long document writes tens of bytes. To run work after edits land (a search index, a preview), declare an after-edit hook instead of doing it in the function that edits. 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.
The binding writes some edits as a delete and an insert: splitting or joining paragraphs moves text into new characters, and a cursor on a moved character no longer names it. The editor that made the edit (or undid one) follows each range through the transaction, keeps drawing it, and calls 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 the useRoom hook from @pylonsync/react. It returns the live peer list plus a setPresence you call as the local cursor moves:
Each 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):
For incremental updates (smaller bytes, useful for over-the-wire sync):

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) and loro-swift wrap 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 (matches crates/router/src/lib.rs::encode_crdt_frame and packages/loro/src/wire.ts):
Type bytes: 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
Mix CRDT and non-CRDT fields freely on the same entity. 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