DEVELOPER GUIDE

Client SDK

Configure RemoteStorage and read, write, delete and observe JSON values.

Use the workspace build

The demo consumes @usex/sync-client through Bun workspaces. Build it before running a consumer:

bun run --cwd packages/sync-client build

Use the version packaged from this repository when evaluating this implementation; an independently published registry release may differ.

Create a client

import { RemoteStorage } from "@usex/sync-client";

const storage = new RemoteStorage({
  serverUrl: "http://localhost:3000",
  apiKey: developmentKey,
  userId: "demo-user",
  instanceId: "browser-tab-a",
  namespace: "my-app:tab-a",
  mode: "websocket",
  autoConnect: false,
});

await storage.connect();
await storage.setItem("preferences", { theme: "dark" });
const preferences = storage.getItem("preferences");
await storage.removeItem("preferences");
storage.destroy();

Use a trusted backend to bind production user IDs to authenticated sessions. A shared key in a public browser does not provide per-user authorization. See API keys and security.

Core operations

MethodBehavior
connect()Opens transport, flushes pending writes and downloads the server snapshot.
getItem(key)Synchronously reads the local cache; missing values return null.
setItem(key, value, metadata?)Stores JSON locally and queues delivery; await it to observe online errors.
hasItem(key)Asynchronously checks existence; use it to distinguish missing from stored null.
getAllKeys()Returns locally cached keys.
getAllItems()Asynchronously returns items; connected clients can fetch current server data.
removeItem(key)Queues a deletion.
clear()Asynchronously queues deletion of all keys for the user. Await it.
disconnect()Stops transport while retaining cached values and pending writes.
forceSync()Attempts to flush pending work; it does not grant connectivity.
destroy()Releases listeners, transport and timers when the client is no longer owned.

Values must be JSON-serializable. false, zero, an empty string and null are supported; undefined, functions, circular references and BigInt are not JSON values. Encode large integers as strings.

Observe changes

const onChange = (event) => {
  console.log(event.key, event.source);
  render(storage.getItem(event.key));
};

storage.on("change", onChange);
// Later, before the owning component goes away:
storage.off("change", onChange);

Use connect, disconnect, reconnect and error for connection UI. A local change event is not proof of remote delivery; handle the write promise and observe a receiving client separately.

Select a transport

  • websocket: Socket.IO with acknowledgements and server events.
  • http: REST with polling-based subscriptions; updates are not instant push events.
  • auto: attempts WebSocket and can fall back to HTTP.

Use the server origin for WebSocket/auto. HTTP defaults to /api/v1; an explicit path in the server URL can override it. Raw WebSocket connections are not compatible with Socket.IO.

The SDK now selects Socket.IO's WebSocket transport directly. Custom Socket.IO clients must use transports: ["websocket"]; Engine.IO long-polling is disabled. Upgrade SDK clients together with the server. Multiple backend replicas must share MongoDB and Redis; see scaling.

Namespaces and compatibility

Queued mutations now retain an idempotency ID across retries and transport switches; see safe retries. Bind credentials to a project and key prefixes, and use distinct browser cache namespaces for each project and permission boundary. Use getHistory, previewRestore, and restoreVersion for version recovery.

Server events include eventId, recordId and version for durable outbox delivery. The SDK suppresses duplicate and stale versions. Custom clients must handle at-least-once events, retain deletion versions, and reconcile snapshots after reconnect. Mutation responses confirm that data and its event are saved; dispatch can lag during Redis outages.

Use distinct cache namespaces for independent clients on the same origin. A namespace separates browser storage; it is not a server-side access-control boundary.

The core package exposes storage operations. React hooks live in @usex/sync-client/react, and transport and diagnostics tools live in @usex/sync-client/advanced. Deprecated hooks are available only from @usex/sync-client/legacy.

Unsupported configuration has been removed: debug, network, storage.encryptionKey, storage.compressionEnabled, storage.cleanupStrategy, storage.maxItemSize, and conflict.mergeStrategy. JavaScript callers receive an explicit error for these options. Use logger for client lifecycle logging and storage.maxSize for the enforced local capacity limit. Cache encryption and browser background execution are not supported.

Tenants and branches

The credential selects the tenant and project. Set a unique cache namespace for each backend/tenant/project. Use the branch option (default main) to bind both HTTP and WebSocket traffic to a branch. See Multi-tenancy and Branches and commits for isolation and snapshot APIs.

Know when work is safe

await storage.ready() waits for pending delivery, the initial server snapshot and subscriptions. A connected socket alone is not readiness. Failed initialization can be retried by calling ready() again.

const stop = storage.on("status", ({ state, pendingCount, lastAcknowledgedAt }) => {
  console.log(state, pendingCount, lastAcknowledgedAt);
});
await storage.ready();
await storage.flush();
await storage.close({ flush: true });
stop();

getStatus() exposes offline, initializing, syncing, synced, error, or closed, plus ready, pendingCount, lastAcknowledgedAt and error. Acknowledgement confirms server persistence, not receipt by every other device. flush() rejects if disconnected with pending work. A failed close({ flush: true }) leaves the client open for retry. Plain close() releases resources while retaining persisted pending work; memory-only storage cannot survive a process or page exit.

Connect a browser without exposing your application key

Your authenticated application backend calls POST /api/v1/client-sessions with its server-held application key, x-user-id derived from the authenticated session, x-instance-id, and an optional x-branch. Never trust a user ID submitted by the browser. The parent key must permit reads and the POST method; its existing origin, IP, HTTPS, prefix and quota rules continue to apply.

Return only the response payload to that authenticated user, with Cache-Control: no-store. It contains token, expiresAt, userId, branch and cacheScope. The token lasts at most 15 minutes and cannot outlive its parent key. It cannot create another session or access administrator endpoints. Parent-key deactivation, rotation and current policy restrictions are checked on subsequent operations and event delivery, including across replicas.

import { connectBrowser } from "@usex/sync-client";

const { storage, expiresAt } = await connectBrowser({
  serverUrl: "https://sync.example.com",
  session: async () => {
    const response = await fetch("/my-app/sync-session", { cache: "no-store" });
    if (!response.ok) throw new Error("Sign in to connect");
    return response.json();
  },
});

/my-app/sync-session is an endpoint you implement in your application, not a Sync Store endpoint. Use HTTPS and keep tokens out of URLs and persistent browser storage. The helper derives cache isolation from the backend URL and server-issued scope; renewing a session or rotating the same parent key retains that scope. A different user, branch, key or access policy uses another scope. On logout, close the old client before connecting as another user.

Session renewal is explicit: before expiresAt, close the old client and call connectBrowser again. Persisted queued work is recovered with the same scope. No automatic renewal or cross-scope transfer of queued writes is performed.

Typed keys and runtime validation

const storage = new RemoteStorage<{ count: number }>({
  serverUrl,
  apiKey,
  userId,
  namespace: "my-isolated-scope",
  autoConnect: false,
  schema: {
    count(value) {
      if (typeof value !== "number" || !Number.isInteger(value)) {
        throw new Error("Count must be an integer");
      }
    },
  },
});
await storage.setItem("count", 3);

The generic checks key names and values at compile time. Optional schema callbacks validate writes and incoming values at runtime; unknown keys are rejected when a schema is supplied. Validation happens before a local write enters the queue. All writes reject non-JSON data such as NaN, undefined and cyclic or class instances. Client validation is not a server security boundary: other clients can still write JSON through the API. Server-enforced project schemas and code generation are not implemented.

Bootstrap the first render

After a request-scoped server client is ready, call dehydrate() and pass the result as initialSnapshot when creating the corresponding browser client. Endpoint, namespace, user and branch must match. Serialize snapshots with your framework's safe data serializer, never by interpolating JSON into an HTML script tag. Only send the authenticated user's permitted data; snapshots are visible to the browser.

Bootstrap seeds missing cache entries without enqueueing writes or claiming readiness. Existing cache and pending local work win. Pending keys are omitted from snapshots. Call ready() to reconcile with the server. A snapshot is a first-render cache, not a backup or a guarantee of freshness. For isolated tests, pass a Web Storage-compatible memory implementation through storage.adapter; this also stores the pending queue.

On this page