Thin Client

Learn how to set up the thin browser client, which talks to your backend with no local database.

The default browser client: about 160 KB gzipped, with no Wasm and no local storage. Your main backend runs every query under the signed-in user's policies and sends finished rows, and writes commit on the server.

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

The thin client is what you get with no choice made: JazzProvider in React, Vue or Svelte, or createJazzClient from jazz-tools/client, without a client loader.

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

In the Next.js App Router, JazzProvider is a client component. Put it in a file that starts with "use client" and render that from app/layout.tsx around {children}:

app/providers.tsx
"use client";
import { JazzProvider } from "jazz-tools/react";
​
export function Providers({ children }: { children: React.ReactNode }) {
return <JazzProvider signedOut={<SignIn />}>{children}</JazzProvider>;
}

Server components and route handlers read through jazz.forReqUser instead.

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

There is no driver option, since the thin client keeps nothing locally.

What runs on the server

  • Transactions (mergeable and serializable), branch and snapshot views (db.captureSnapshot, branch/base options), large-value streaming (insertStreaming / upsertStreaming)

Limitations

Each is an explicit "not supported" error:

  • no offline use: while the backend is unreachable, reads and subscriptions wait and are answered after the reconnect; writes are not optimistic: they show after one round trip, and wait() resolves when the server answered
  • no attributed writes
  • no streaming inside a transaction
  • text edits with editedFrom on merge text columns are rejected (NotObserved) for now; use the local-first client for collaborative text editing
  • pending writes live in memory only: a write the server hasn't answered yet is lost if the page reloads or closes (see Pending writes below)
  • a single write, and a single answer to a query, can be at most 32 MiB. The client refuses a larger write itself; streamed writes (insertStreaming and the like) go in pieces, so a larger file uploads fine. The server answers a larger result with a TooLarge error for that query alone (ending a live query) and keeps the session, so one very large row fails every query that includes it. Read such a value a page at a time with a range select, such as select({ content: { from, to } }), until streaming reads arrive
  • no admin secret in the browser

Pending writes

When the socket drops, the client reconnects on its own and resends any write the server hadn't answered. Standalone inserts, updates, upserts, deletes and restores carry a key the server deduplicates by, so a resend lands exactly once. getSyncStatus() and useSyncStatus() show whether the client is connected and how many writes still wait for an answer.

A write ends with ThinConnectionLostError (from jazz-tools/thin) only when the connection ends for good while it waits: after reauthenticate() signs in someone else, after disconnect(), or when the client closes. It may or may not have committed, so read before you retry. A transaction in flight when the socket drops ends the same way.

Pending writes are kept in memory, not on disk. To warn before the user leaves with unsent changes:

window.addEventListener("beforeunload", (event) => {
if (db.getSyncStatus().pendingUploads > 0) event.preventDefault();
});

For writes that survive a reload, upgrade to local-first.

Upgrading to local-first

Hand the provider a loader for the local-first client. It starts thin, then upgrades in the background after the first load. The session keeps the same Db: live queries and listeners carry on, restarting from the local-first client's first result, and the thin client's pending writes finish first:

<JazzProvider client={() => import("jazz-tools/local-first")}>

The dynamic import lives in your code, so the local-first client and its Wasm become a separate chunk: apps that never pass client never download it.

To upgrade only when you need to, for example when a user opens an editor or goes offline, call the hook instead:

const { upgradeToLocalFirst } = useDb();
await upgradeToLocalFirst(() => import("jazz-tools/local-first"));

Without a framework, call the same method on the session's Db: await db.upgradeToLocalFirst(() => import("jazz-tools/local-first")). The session upgrades in place, so you keep using the same Db. See Local-First Client.