Skip to main content
For collaborative-editor-style features (Google Docs, Figma, Linear-style multiplayer), Pylon integrates with Loro — a fast, well-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], and 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 — no separate install.

Declare CRDT-backed fields

In your schema:
CRDT container overrides are set with .crdt(annotation) on a field builder. "text" and "counter" are wired end-to-end today; "list", "movable-list", and "tree" are reserved (wire format locked in, server-side projection still landing) — but you can still edit list/map/tree containers on the per-row LoroDoc (see below), because the server stores every CRDT payload as opaque bytes and never interprets it. The Pylon server stores CRDT fields as opaque bytes — it doesn’t 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.

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.

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).

Saving and loading

The engine handles persistence transparently — 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 — 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 surface — 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 doesn’t care what’s inside payload — that’s Loro’s binary format. To peek/decode 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 NOT to use CRDTs

CRDTs are great for collaborative state where two users might edit at the same time. They’re overkill 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, but for very long-lived docs, periodically save a full snapshot to bound the size of the update history. The engine handles this automatically.
  • Garbage collection — Loro tracks tombstones for deletes; doc.compact() reclaims memory after large deletions.
  • Presence cleanup — set a TTL on presence entries; users who close the tab without explicitly leaving will linger otherwise.

Examples