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 Client | Local-First Client | |
|---|---|---|
| Bundle | about 160 KB gzipped | adds the Wasm engine, loaded as its own chunk |
| Queries run | on your backend, under the user's policies | in the browser, against a local replica |
| Writes show up | after one round trip | instantly, then sync |
| Offline | no | yes, reads and writes |
| Local storage | none | IndexedDB, per account |
| Wasm and workers | none | a Wasm engine in a shared worker |
| Setup | nothing beyond JazzProvider | a 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.tsximport { JazzProvider } from "jazz-tools/react";function Root() {return (<JazzProvider client={() => import("jazz-tools/local-first")} signedOut={<SignIn />}><App /></JazzProvider>);}
app.tsexport async function createLocalFirstSession() {return createJazzClient({serverUrl: "/jazz",initial: "local-first",// Local data, so the guest works offline from its first visit.client: () => import("jazz-tools/local-first"),});}
The background upgrade to the local-first client keeps the same Db: its live queries, listeners and writes carry on. db.isLocalFirst() tells whether it has happened yet, and await db.upgradeToLocalFirst(loader) waits for it. If the app took the Db offline with disconnect(), it stays offline. In the browser, a local-first Db is offline for every tab of the app, so at the upgrade that also takes the app's other local-first tabs offline. Signing in, signing out and switching accounts do give the session a new Db. The old one then ends its live queries with a DbReplacedError (from "jazz-tools") and throws it on writes. Follow the session to pick up the new one, and read the current Db for each write:
app.ts/*** Run `attach` on the session's current Db, and again each time the session* replaces it: when the user signs in, signs out or switches accounts. (The* upgrade to the local-first client keeps the same Db.)*/export function followDb(session: JazzSession<JazzClient>,attach: (db: Db) => () => void,): () => void {let current: Db | undefined;let detach: (() => void) | undefined;const follow = () => {const next = session.getSnapshot().client?.db;if (next === current) return;detach?.();detach = undefined;current = next;if (next) detach = attach(next);};const stop = session.subscribe(follow);follow();return () => {stop();detach?.();};}
const stop = followDb(session, (db) => db.subscribe(app.todos, renderTodos));session.getSnapshot().client!.db.insert(app.todos, { title: "Buy milk", done: false });
The framework providers do this for you.
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 (localorglobal) to wait for the point you need.
Options
Both clients take these options:
| Option | Default | What it does |
|---|---|---|
serverUrl | /jazz | Where 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 |
clearOnSignOut | false | Wipe the account's local data on sign-out, for shared devices |
The local-first client also takes:
| Option | Default | What it does |
|---|---|---|
driver | { type: "persistent" } | Where the replica lives: IndexedDB, or { type: "memory" } for nothing on disk |
dbName | derived from your endpoint | Logical base name of the local database; Jazz keeps one per endpoint and account |
runtimeSources | found automatically | Where 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, passclearOnSignOut: 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.tsimport { 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'".
next.config.tsimport type { NextConfig } from "next";const nextConfig: NextConfig = {// Your backend loads Jazz's native module; Next must not bundle it.serverExternalPackages: ["jazz-napi"],};export default nextConfig;
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.
| Field | Use it when |
|---|---|
runtimeSources.baseUrl | Jazz runtime assets are served from a shared base path like /assets/jazz/ |
runtimeSources.wasmUrl | The Wasm file has an explicit public URL |
runtimeSources.brokerWorkerUrl | The browser broker worker has an explicit public URL |
runtimeSources.wasmVersion | An immutable deployed build version for configured browser asset URLs |
runtimeSources.wasmSource | Your runtime gives you Wasm bytes directly |
runtimeSources.wasmModule | Your runtime gives you a precompiled WebAssembly.Module |
Jazz resolves these in this order:
runtimeSources.wasmModuleruntimeSources.wasmSourceruntimeSources.wasmUrl/runtimeSources.brokerWorkerUrlruntimeSources.baseUrl- 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.