@pylonsync/sync is the engine the React, React Native, and Next.js clients all wrap. You can use it standalone in any JavaScript host: Vue, Svelte, Solid, vanilla JS, Node, Bun, Tauri, Electron, or Cloudflare Workers.
This page covers the engine’s mental model, configuration, and direct API. For React-specific hooks see React.
Mental model
The sync engine maintains an in-memory replica of the rows the server has shown you. That replica is:- Server-authoritative: every change has a
seqnumber from the server’s append-only change log - Tombstone-aware: deletes can’t be resurrected by an out-of-order replay
- Optimistic: local mutations update the replica immediately; a background queue ships them to the server with idempotency tokens
- Identity-flip-safe: when the auth token or active tenant changes, the replica resets so you don’t see stale rows from the previous identity
- Crash-safe: the persistence layer (IndexedDB on web, an AsyncStorage-backed replica in
@pylonsync/react-native, SQLite in the Swift SDK) writes through every change before advancing the cursor, so a crash mid-pull can never leave the cursor ahead of the durable replica
Install
@pylonsync/react already includes it; install directly only when you’re using the engine without React.
Construct an engine
start():
- Loads cached entities + cursor from IndexedDB
- Hydrates the mutation queue (offline writes survive restart)
- Resolves the current session via
/api/auth/me - Pulls changes since the last cursor
- Connects the chosen real-time transport
Read the local store
store always reflects the post-merged state. The engine applies server pushes and optimistic mutations before listeners fire.
Optimistic mutations
failed. engine.outbox() lists the writes that are not on the server:
id), entity, rowId, kind, the fields it sets (data), and createdAt. The same object is returned until the queue changes. A queue change notifies store subscribers on OUTBOX_CHANNEL.
Retry a failed write, with corrected fields when the server rejected a value. The write is applied locally again and sent under a new op id. The promise settles like insert:
retryWrite rejects with code OWNER_MISMATCH when another user is signed in, and with OWNER_UNKNOWN when the engine does not know who made the write or who is signed in. Dismiss those writes.
Rows written offline stay on screen after a reload: on start, the engine applies the pending writes from the saved queue to the local replica again.
For a simpler per-call API, prefer the React db.useMutation hook. It exposes loading / data / error for the specific call, without reaching into the queue.
Pagination
Hydration
hydrate(...) seeds the local store and the cursor before start() runs, so the first paint is server-rendered and the engine doesn’t waste an initial pull.
Catch-up
The engine pulls from its saved cursor:- New client (cursor 0). The server sends a snapshot of the rows this user can see, not the change log.
- Recent cursor. The server sends the change log since the cursor.
- Far-behind cursor. When the log since the cursor is longer than a snapshot, or the log no longer holds the cursor, the server sends a snapshot instead. The engine applies it over the rows it has, deletes the rows that the snapshot does not hold, and continues from the snapshot’s position in the log. Rows stay on screen during the catch-up. Writes that are not on the server yet are kept.
/api/auth/me answers, before the replica has filled. After a sign-in, the old user’s rows are removed first, then useSession() reports the new user while the new rows arrive. Read useSyncProgress() (pulling, snapshot, rows, pages, synced) to show progress. useSyncStatus() reports the connection.
The automatic reconcile downloads every replicated row to catch rows that the change log does not carry. It runs on a connect or when the tab becomes visible, but only in these cases:
- The app was offline since the replica last matched the server (the connection dropped, or the app started from rows saved by an earlier run), and that match is at least 5 minutes old (
autoReconcileIntervalMs). - A snapshot replaced saved rows but did not say which entities it covers (an older server), so it could not remove deleted rows.
sync-reconcile) still reconcile at once. An explicit engine.reconcile() always runs.
Pages start at 1,000 rows. Each full page doubles the next one, up to 10,000 rows or about 4 MB. A first sync of 25,000 rows takes 5 requests. PYLON_SYNC_PAGE_BYTES sets the byte limit per page on the server.
Auth integration
The engine reads the bearer token from the storage adapter on every request. To set one:session-changed envelope over WS whenever the session is mutated (select-org, clear-org, session revoke, even from other tabs or admin tools), and the engine refreshes /api/auth/me automatically. Tab A switching tenant updates Tab B’s useSession and Tab B’s replica without Tab B’s app code doing anything.
If you mutate a session via a path that bypasses the framework’s auth path (rare, e.g. writing directly to SessionStore from a Rust plugin), call engine.notifySessionChanged() to force the refresh.
Multi-tenant: switching active org
Use the engine’s built-in helper — it POSTs/api/auth/select-org, verifies membership server-side, resets the local replica so stale rows from the previous tenant disappear, and refreshes the cached session in one call:
useSession():
.code (e.g. "NOT_A_MEMBER") and .status carrying the server’s response, so UI code can branch on the specific error without re-parsing the body.
Real-time transport details
WebSocket (primary)
The engine derives the URL frombaseUrl. It defaults to ws://host:port+1 for pylon dev (port + 1 is the WS port). Override it via wsUrl:
bearer.<percent-encoded-token> Sec-WebSocket-Protocol subprotocol because browser WebSocket has no header API.
Each text frame is a JSON ChangeEvent; binary frames go to whichever consumer registered via engine.onBinaryFrame(handler). The Loro CRDT integration uses this for binary CRDT updates.
Reconnect uses full-jitter exponential backoff: random(0, base * 2^attempts), capped at 30s. The backoff counter resets only after a connection has been stable for 5s, so an auth-failure-then-disconnect loop can’t tight-loop reconnect.
SSE (fallback)
transport: "sse" connects to http://host:port+2/events (port + 2). The server emits one JSON ChangeEvent per data: line.
Use SSE when:
- The deployment blocks WebSocket (rare, but some corporate proxies do)
- You only need one-way server→client (no presence, no shard inputs)
Polling (last resort)
transport: "poll" calls /api/sync/pull every pollInterval ms. Use only when SSE and WebSocket are both unavailable.
CRDT subscriptions
Binary CRDT replication is available only for synced entities with public fields. It is disabled for the configured User entity and for entities withserverOnly, syncOmit, or vector fields. Raw CRDT history cannot apply field-level projection. This rule also protects documents stored before a field became private.
The server records this restriction in the database. Removing a private field from the schema does not enable binary replication of its old history. Running servers check the shared restriction before sending binary data.
On the first upgrade that adds this record, all entities with stored CRDT snapshots become restricted. The server cannot prove that their old history has no private fields. New public entities can use binary replication. Existing restricted entities can use JSON updates and filtered JSON sync.
These entities still use projected JSON sync. Use the JSON update API for client writes. To use collaborative text with private data, store the private fields in a separate entity.
For collaborative rows backed by Loro CRDTs:
useLoroDoc callers on the same row don’t unsubscribe each other when one unmounts. The engine re-sends active subscriptions on every reconnect so binary frames keep arriving on a fresh socket.
See Loro for the higher-level integration.
Presence + topics
Persistence
The engine writes through to IndexedDB by default in browsers. The schema:entitiesstore — keyedentity:row_id, value{ entity, id, data }cursorsstore — keyedcursor, value{ last_seq }pendingMutationsstore — keyedid, value{ id, change, status, error? }
push() tick.
For non-browser hosts (RN, Tauri, Electron, native Swift), pass a different persistence backend — @pylonsync/react-native ships AsyncStorageReplicaPersistence; the Swift SDK has its own SQLite implementation.
Resetting
resetReplica() clears the in-memory store, sets the cursor to 0, and persists both. Doesn’t trigger a pull — the caller decides when.
Configuration reference
Direct calls (skip the engine)
For one-off operations that don’t need the local store, use the low-levelpylonFetch primitive — it builds the request (base URL + bearer token) and parses JSON, without touching the engine’s replica:
@pylonsync/react also ships convenience fetchList / fetchById wrappers that read the configured client’s base URL + token for you.)