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

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