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, 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 with 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 — server pushes and optimistic mutations are both applied before listeners fire.

Optimistic mutations

If the server rejects a mutation, the engine marks it failed in the queue (engine.mutations, a MutationQueue) and keeps it there — it isn’t silently dropped. Look one up by op id with engine.mutations.get(id) (its .status is "pending" | "applied" | "failed", with .error set on failure), and drop it once resolved:
For a per-call ergonomic surface, 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.

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 surface (rare — e.g. directly writing 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)

URL is derived from baseUrl — defaults to ws://host:port+1 for pylon dev (port + 1 is the WS port). Override 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 backoffrandom(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). 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

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