Local-First Client

Learn how to set up the local-first browser client, from bundler config to local storage and sync.

The local-first client keeps a replica of the data each user can read in the browser. Queries answer from it instantly, writes show up before they sync, and the app keeps working offline. It loads as its own chunk on top of the thin client.

When to pick it

Jazz has two browser clients. Both use the same schema, queries, permissions and Db API, so you can start thin and upgrade to local-first later without changing app code.

Thin ClientLocal-First Client
Bundleabout 160 KB gzippedadds the Wasm engine, loaded as its own chunk
Queries runon your backend, under the user's policiesin the browser, against a local replica
Writes show upafter one round tripinstantly, then sync
Offlinenoyes, reads and writes
Local storagenoneIndexedDB, per account
Wasm and workersnonea Wasm engine in a shared worker
Setupnothing beyond JazzProvidera client loader and a few bundler lines

Both need React 19 or later for the React bindings (jazz-tools declares react >=19, so npm refuses to install it next to React 18).

Pick the thin client by default. Pick local-first when users need instant UI on every change, work offline, or keep large working sets that should not round-trip on every read.

Connect

Pass a loader for the local-first client. The app starts thin, then upgrades in the background after the first load:

App.tsx
import { JazzProvider } from "jazz-tools/react";
​
function Root() {
return (
<JazzProvider client={() => import("jazz-tools/local-first")} signedOut={<SignIn />}>
<App />
</JazzProvider>
);
}

A browser that has upgraded this account before opens it local-first straight away, so Jazz works offline on later visits. Loading the page itself offline is up to your app: cache the app shell, including Jazz's Wasm and worker files, with a service worker of your own. If a background upgrade fails, the app stays on the thin client and logs a warning. That tab then has no offline use, and it doesn't retry the upgrade until the page reloads.

The import() stays in your code, so the Wasm engine (about 3 MB gzipped, 8.7 MB uncompressed) downloads only when the client upgrades. After the first render, local storage keeps filling for a few seconds. Writes made on the thin client before the upgrade are kept offline only when a query the client subscribes to covers them. The local-first chunk and Wasm load lazily, so when you deploy a new build, keep the previous builds' hashed assets available for a while: tabs still open on the old build fetch them when they upgrade. To upgrade only on demand, call useDb().upgradeToLocalFirst(() => import("jazz-tools/local-first")) instead; see Upgrading to local-first.

Sign-in

Both clients sign in the same way: they ask your main backend at /jazz who is signed in, with your app's own session cookie, and your backend's authenticate decides who that user is. The client asks on start, when the page regains focus and when another tab signs in or out. After your own sign-in or sign-out, call reauthenticate() (from useJazzAuth() in React) so the change shows at once. The browser never holds a Jazz key or token. See How Auth Works.

Reads and writes

Both clients return the same Db: queries, subscribe, insert, update, upsert, delete, transactions and the framework hooks work unchanged. The difference is when a write shows up:

  • Thin client. A write shows after one round trip to your backend, and wait() resolves when the server has answered.
  • Local-first client. A write shows instantly in every local query, then syncs. wait() takes a durability tier (local or global) to wait for the point you need.

Options

Both clients take these options:

OptionDefaultWhat it does
serverUrl/jazzWhere your main backend mounts Jazz. Set it only if you mount it somewhere else
initial"signed-out""local-first" starts visitors as local-first guests
clearOnSignOutfalseWipe the account's local data on sign-out, for shared devices

The local-first client also takes:

OptionDefaultWhat it does
driver{ type: "persistent" }Where the replica lives: IndexedDB, or { type: "memory" } for nothing on disk
dbNamederived from your endpointLogical base name of the local database; Jazz keeps one per endpoint and account
runtimeSourcesfound automaticallyWhere to load the Wasm and worker from; see Wasm and workers
logLevel"warn"Wasm log level, for debugging

Local storage

By default the replica lives in IndexedDB, kept apart per endpoint and account (jazz-idb, plus one database per account named with its URL-encoded account id), so two users on the same browser never share data. driver: { type: "memory" } keeps it in memory only, and it is gone when the tab closes. await db.deleteClientStorage() clears the current one; see Resetting browser storage.

An app upgraded from alpha.59 that had local-first guests passes its old app ID as scope (createJazzClient({ scope: "<your alpha.59 app ID>" })), so those guests keep their accounts. New apps leave it out. See Upgrading from alpha.59.

What stays on the device:

  • Signing out closes the account's client but keeps its local data, synced rows included, for that user's next sign-in, and the remembered user (account, identity and claims, never a credential) stays in localStorage. On shared devices, pass clearOnSignOut: true; see the shared device checklist. It wipes the account's data, but the empty per-account database and the list of account ids on this device remain.
  • Your schema, including tables a user can't read, ships in the app bundle. Permissions decide which rows a user can read, not which tables they can see exist.

Eviction

{ type: "persistent" } writes to IndexedDB. By default browsers treat this as best-effort storage — under disk pressure, or after long inactivity, the browser can clear it without warning. For a local-first app that means a returning user can find their identity (the local-first secret) and any data owned only by that user wiped.

The trade-offs are:

  • Best-effort (default). No prompt, no friction. Acceptable when the server holds a copy of every row the user cares about, or when the app gracefully reseeds from sync. Anything that exists only on this device is at risk.
  • Persistent. Storage is only cleared by an explicit user action (clearing site data, uninstalling). Required for true offline-first apps and for any data that doesn't round-trip through a server. Browsers gate this behind navigator.storage.persist(), which may show a prompt or grant silently based on engagement heuristics.

To request persistent storage, call it once after the user has signed in or interacted enough that a prompt makes sense:

if (navigator.storage?.persist) {
const granted = await navigator.storage.persist();
if (!granted) {
// Fall back: rely on sync, warn the user, or retry later.
}
}

Check navigator.storage.persisted() on subsequent loads to see whether the grant is still in effect. Pair this with the backup and restore flow so the local-first secret can be recovered if storage is ever cleared.

Offline and guests

Reads and writes keep working offline and sync when the connection returns. A signed-out visitor can also start as a local-first guest: an account created on the device, which keeps its data when the visitor signs up later.

Wasm and workers

Bundler config

There is no Jazz plugin to add. In development, run your app as usual: createJazz in your main backend is the dev server, and it publishes your schema, permissions and migrations every time it starts.

The local-first client loads Jazz's Wasm in the browser, and a Next.js backend loads Jazz's native module (jazz-napi) on the server. Tell your bundler to leave them alone:

vite.config.ts
import { defineConfig } from "vite";
​
export default defineConfig({
// plugins: your framework's plugin (React, Vue, Svelte, Solid, SvelteKit) ...
// Load the Wasm as a real module instead of pre-bundling it.
optimizeDeps: { exclude: ["jazz-wasm"] },
});

It needs Vite 6 or later: Vite 5 answers the local-first chunk with a 500, "Vite is unable to parse the worker options as the value is not static". This is the same for React, Vue, Svelte, Solid and SvelteKit. With pnpm, also add jazz-wasm as a direct dependency of your app so its strict layout resolves it.

SvelteKit also needs ssr: { external: ["jazz-napi"] } in the same config, because its server runs Jazz too: without it, vite build bundles Jazz's native module and fails with "Cannot find module './index.cjs'".

The thin client has no Wasm, so it needs no Vite line. A Next.js app that imports jazz-tools/backend still needs serverExternalPackages.

Wasm loading

Use runtimeSources when Jazz can't automatically find its internal assets. This usually only happens with non-standard bundler configurations.

FieldUse it when
runtimeSources.baseUrlJazz runtime assets are served from a shared base path like /assets/jazz/
runtimeSources.wasmUrlThe Wasm file has an explicit public URL
runtimeSources.brokerWorkerUrlThe browser broker worker has an explicit public URL
runtimeSources.wasmVersionAn immutable deployed build version for configured browser asset URLs
runtimeSources.wasmSourceYour runtime gives you Wasm bytes directly
runtimeSources.wasmModuleYour runtime gives you a precompiled WebAssembly.Module

Jazz resolves these in this order:

  1. runtimeSources.wasmModule
  2. runtimeSources.wasmSource
  3. runtimeSources.wasmUrl / runtimeSources.brokerWorkerUrl
  4. runtimeSources.baseUrl
  5. built-in zero-config fallback

Use baseUrl when both assets live together under one public directory. Use explicit wasmUrl / brokerWorkerUrl when your app serves them from different places.

For browser URL overrides, also set wasmVersion to an immutable value that changes whenever either the Wasm or broker-worker build changes (for example, your deployment's asset manifest version). Jazz adds that value to both asset URLs, so a rolling deployment cannot reuse a long-lived SharedWorker with bytes fetched from an older build at the same URL. Your asset host must serve these URLs with query strings intact.

Worker failures

After the shared worker acknowledges startup, runtime initialisation has a five-minute grace period. If it does not become ready, startup reports an error instead of leaving the client pending indefinitely. Suspended tabs receive fresh grace when they resume.

While control requests are waiting for worker replies, Jazz probes the connection every 30 seconds and allows 30 seconds for a response. These are connection-health checks, not query or storage-operation timeouts: a responsive worker can continue a long operation, and idle connections are not continuously probed.

An unresponsive-worker error does not prove that an outstanding operation failed. It may already have completed. Jazz does not automatically retry operations with an unknown outcome or clear IndexedDB. Use the existing client and session lifecycle to recover, and establish a mutation's outcome before retrying it. Terminal shutdown reports the causal failure without waiting indefinitely for an unresponsive worker; it does not claim that an unacknowledged durable handoff succeeded.

An intentional storage reset releases the old foreground identity without returning it to the erased store. deleteClientStorage() waits for a fresh identity before the same application client can be used again. If shutdown starts during that acquisition, Jazz retires the unused identity instead. Inspector attachments are session-scoped: after a reset, open a new attachment rather than reusing the old one.