Skip to main content
@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 seq number 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
The engine subscribes to the server via one of three transports: WebSocket (primary), SSE (fallback), or polling (last resort). All use full-jitter exponential reconnect.

Install

@pylonsync/react already includes it; install directly only when you’re using the engine without React.

Construct an engine

start():
  1. Loads cached entities + cursor from IndexedDB
  2. Hydrates the mutation queue (offline writes survive restart)
  3. Resolves the current session via /api/auth/me
  4. Pulls changes since the last cursor
  5. Connects the chosen real-time transport

Read the local store

The store always reflects the post-merged state. The engine applies server pushes and optimistic mutations before listeners fire.

Optimistic mutations

If the server rejects a mutation, the engine rolls it back and keeps it in the queue as failed. engine.outbox() lists the writes that are not on the server:
Each item has the op id (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:
Dismiss a failed write after the user has seen it:
A write is sent only as the user who made it. 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.
The session resolves as soon as /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.
It never runs right after a complete snapshot or only because the tab came to the foreground. Server hints (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:
Or inject a custom adapter (RN’s AsyncStorage bridge, a Tauri-store wrapper):
The server pushes a 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:
React apps get the same helpers off useSession():
All three throw on non-2xx with .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 from baseUrl. It defaults to ws://host:port+1 for pylon dev (port + 1 is the WS port). Override it via wsUrl:
The engine sends the auth token via the 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)
The reconnect backoff matches the WebSocket path.

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 with serverOnly, 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:
Subscriptions are refcounted — two 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

Both ride the same WebSocket. Subscribers see updates via the store notifier (presence) or by registering their own handler (topics).

Persistence

The engine writes through to IndexedDB by default in browsers. The schema:
  • entities store — keyed entity:row_id, value { entity, id, data }
  • cursors store — keyed cursor, value { last_seq }
  • pendingMutations store — keyed id, value { id, change, status, error? }
On startup, the engine loads every entity row and the cursor, then catches up via pull. Mutations queued offline are hydrated and pushed on the next 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-level pylonFetch primitive — it builds the request (base URL + bearer token) and parses JSON, without touching the engine’s replica:
These hit the same endpoints but don’t update the engine’s local store. Use for server-side scripts or operations the user shouldn’t see in the UI. (@pylonsync/react also ships convenience fetchList / fetchById wrappers that read the configured client’s base URL + token for you.)