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 buildUse 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
| Method | Behavior |
|---|---|
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.