TanStack
Guides

SQLite Persistence

SQLite persistence stores Collection rows in a SQLite database supplied by the runtime. A new Collection can load those rows when it uses the same database. The wrapper can also save sync metadata so a supported sync adapter can resume safely.

Choose a package for your runtime. Each runtime package supplies a SQLite driver and re-exports persistedCollectionOptions from @tanstack/db-sqlite-persistence-core.

SQLite persistence stores Collection data. To retain mutations that still need a server response, use offline transactions. The combined React Native recipe shows both stores together.

Choose a runtime package

Each package connects through its runtime's database, storage, or IPC interface. Follow its README for setup and required native plugins.

RuntimePackage and setupFactory
Browser with OPFS@tanstack/browser-db-sqlite-persistencecreateBrowserWASQLitePersistence
Node.js@tanstack/node-db-sqlite-persistencecreateNodeSQLitePersistence
React Native with OP-SQLite@tanstack/react-native-db-sqlite-persistencecreateReactNativeSQLitePersistence
Expo with expo-sqlite@tanstack/expo-db-sqlite-persistencecreateExpoSQLitePersistence
Capacitor@tanstack/capacitor-db-sqlite-persistencecreateCapacitorSQLitePersistence
Tauri@tanstack/tauri-db-sqlite-persistencecreateTauriSQLitePersistence
Electron@tanstack/electron-db-sqlite-persistencecreateElectronSQLitePersistence in a renderer
Cloudflare Durable Objects (server)@tanstack/cloudflare-durable-objects-db-sqlite-persistencecreateCloudflareDOSQLitePersistence

The core package has no SQLite engine binding. Use a runtime package unless you are implementing a new driver.

Start in the browser

Install the browser wrapper and its SQLite engine:

shell
npm install @tanstack/db @tanstack/browser-db-sqlite-persistence @journeyapps/wa-sqlite

This example uses one browser tab. Open an OPFS database, then create one persistence instance for it. Give every persisted Collection a stable id and a getKey function. Set schemaVersion to track the stored row format. Increase it when that format changes.

ts
import { createCollection } from '@tanstack/db'
import {
  createBrowserWASQLitePersistence,
  openBrowserWASQLiteOPFSDatabase,
  persistedCollectionOptions,
} from '@tanstack/browser-db-sqlite-persistence'

type Todo = {
  id: string
  title: string
  completed: boolean
}

const database = await openBrowserWASQLiteOPFSDatabase({
  databaseName: 'app.sqlite',
})
const persistence = createBrowserWASQLitePersistence({ database })

const todos = createCollection(
  persistedCollectionOptions<Todo, string>({
    id: 'todos',
    getKey: (todo) => todo.id,
    persistence,
    schemaVersion: 1,
  }),
)

await todos.preload()
const transaction = todos.insert({
  id: '1',
  title: 'Buy milk',
  completed: false,
})
await transaction.isPersisted.promise

This Collection has no sync option. Its normal insert, update, and delete handlers save local mutations to SQLite. It does not contact a server. The browser must support OPFS and run in a secure context. If multiple tabs can open the same database, use the multi-tab setup.

Manual transactions on a local Collection

For a Collection without a sync option, normal Collection mutations persist automatically. A manual transaction must call acceptMutations in its mutation function:

ts
import { createTransaction } from '@tanstack/db'

const manualTransaction = createTransaction({
  autoCommit: false,
  mutationFn: async ({ transaction }) => {
    await todos.utils.acceptMutations(transaction)
  },
})

manualTransaction.mutate(() => {
  todos.insert({ id: '2', title: 'Call home', completed: false })
})

await manualTransaction.commit()

Load rows after reopening the database

Keep the database open while its Collections run. To simulate a page restart, clean up the Collection and close the database:

ts
await todos.cleanup()
await database.close?.()

Open the same OPFS database and create a Collection with the same id and schemaVersion. Load its rows before you read them:

ts
const reopenedDatabase = await openBrowserWASQLiteOPFSDatabase({
  databaseName: 'app.sqlite',
})
const reopenedPersistence = createBrowserWASQLitePersistence({
  database: reopenedDatabase,
})
const reopenedTodos = createCollection(
  persistedCollectionOptions<Todo, string>({
    id: 'todos',
    getKey: (todo) => todo.id,
    persistence: reopenedPersistence,
    schemaVersion: 1,
  }),
)

await reopenedTodos.preload()
console.log(reopenedTodos.get('1')?.title) // 'Buy milk'

await reopenedTodos.cleanup()
await reopenedDatabase.close?.()

Add persistence to a synced Collection

Pass the options from a sync adapter into persistedCollectionOptions. The wrapper keeps that adapter's sync behavior and stores its applied data in SQLite.

For example, you can wrap a Query Collection. This separate example uses the imports and Todo type from the browser example above:

ts
import { QueryClient } from '@tanstack/query-core'
import { queryCollectionOptions } from '@tanstack/query-db-collection'

const syncedDatabase = await openBrowserWASQLiteOPFSDatabase({
  databaseName: 'synced.sqlite',
})
const syncedPersistence = createBrowserWASQLitePersistence({
  database: syncedDatabase,
})
const queryClient = new QueryClient()

const syncedTodos = createCollection(
  persistedCollectionOptions<Todo, string>({
    ...queryCollectionOptions<Todo, string>({
      id: 'synced-todos',
      queryClient,
      queryKey: ['todos'],
      queryFn: async () => {
        const response = await fetch('http://localhost:3000/api/todos')
        if (!response.ok) throw new Error('Could not load todos')
        return (await response.json()) as Array<Todo>
      },
      getKey: (todo) => todo.id,
    }),
    persistence: syncedPersistence,
    schemaVersion: 1,
    initialRender: {
      strategy: 'network-first',
      networkTimeoutMs: 3_000,
    },
  }),
)

Install @tanstack/query-core and @tanstack/query-db-collection to use this example. Change the URL to your server. You can wrap another Collection sync adapter in the same way.

The initialRender option is optional. It lets live queries report when SQLite restore completes. It also lets React and Solid Suspense use restored rows for the first render while the Collection is not ready.

Keep syncedDatabase open while syncedTodos runs. During normal shutdown, await syncedTodos.cleanup() and then syncedDatabase.close?.().

The sync option determines which mode the wrapper uses:

Collection optionsSQLite behavior
sync presentLoad persisted rows, then apply source sync transactions. The source remains responsible for current data.
sync absentLoad and save local rows through a loopback sync adapter. The Collection has no remote source.

The wrapper also supports syncMode: 'on-demand'. In that mode, it loads rows for active query demand rather than loading every stored row into the Collection.

Render after SQLite restore

initialRender requires an eager persisted Collection. It does not support syncMode: 'on-demand', which has no complete startup snapshot. Set it on every source Collection in a live query. If one source does not opt in, the query cannot report persisted readiness or use the restored snapshot as its initial-render fallback.

Install the React binding to use the following components:

shell
npm install @tanstack/react-db

A regular React live query exposes both readiness signals:

tsx
import { useLiveQuery } from '@tanstack/react-db'

function TodosView() {
  const result = useLiveQuery((q) => q.from({ syncedTodos }))
  const readiness = result.isReady
    ? 'Collection ready'
    : result.isPersistedReady
      ? 'SQLite restore complete. Collection readiness pending'
      : 'Waiting for Collection or SQLite restore'

  return (
    <>
      <p>{readiness}</p>
      <ul>{result.data.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>
    </>
  )
}

isReady reports Collection readiness. isPersistedReady reports that every opted-in source finished its current eager restore, even when SQLite had no rows. These flags do not identify where each visible row came from. A regular useLiveQuery does not wait for the network-first deadline before it renders rows.

The hooks also expose persistedStatus and persistedError. The status is unavailable if a source did not opt in, loading during restore, ready after every restore completes, or error if a restore fails. persistedError keeps the restore error. An SSR seed does not count as a SQLite restore.

React, Solid, and Svelte read these values as properties. Angular reads signals such as result.isPersistedReady(). Vue reads refs such as result.isPersistedReady.value.

React and Solid Suspense can use the same option for network-first initial rendering. This React component uses the syncedTodos Collection configured above:

tsx
import { Suspense } from 'react'
import { useLiveSuspenseQuery } from '@tanstack/react-db'

function TodosList() {
  const { data } = useLiveSuspenseQuery((q) => q.from({ syncedTodos }))
  return <ul>{data.map((todo) => <li key={todo.id}>{todo.title}</li>)}</ul>
}

function TodosScreen() {
  return (
    <Suspense fallback={<p>Loading todos...</p>}>
      <TodosList />
    </Suspense>
  )
}

Place TodosScreen inside an Error Boundary to handle initial failures.

Suspense prefers Collection readiness. If it remains pending after networkTimeoutMs, a completed SQLite restore can release the first render. The default is 3 seconds. Set networkTimeoutMs: 0 to allow fallback as soon as restore completes. For a query with multiple sources, the longest configured deadline applies. The deadline does not stop network sync.

A source failure can allow fallback before the deadline. React also waits for restore if its client query stream fails before the first render, including before the component mounts. Once every restore succeeds, React renders the derived query result, even if it is empty.

A derived-query failure still reaches the surrounding React Error Boundary. If the network-first wait rejects, its error reaches that boundary. A successful network load can render even if restore failed or a source did not opt in.

Persisted readiness does not prove current authorization. Isolate SQLite data by user or tenant. If an authorization failure must hide cached rows, block the view separately. The fallback does not classify source errors.

Browser tabs and Electron renderers

The browser package uses single-tab coordination by default. If tabs share one OPFS database, give the persistence instance a BrowserCollectionCoordinator. It elects an owner for each Collection and routes writes between tabs. Replace the initial database and persistence setup with this code in each tab:

ts
import { BrowserCollectionCoordinator } from '@tanstack/browser-db-sqlite-persistence'

const database = await openBrowserWASQLiteOPFSDatabase({
  databaseName: 'app.sqlite',
})
const coordinator = new BrowserCollectionCoordinator({ dbName: 'app' })
const persistence = createBrowserWASQLitePersistence({
  database,
  coordinator,
})

Pass this persistence instance to persistedCollectionOptions. During normal shutdown, clean up the Collections, dispose the coordinator, and close the database.

The Electron package uses a main-process SQLite owner and a renderer bridge. Add ElectronCollectionCoordinator when multiple renderers share the database.

These coordinators belong to SQLite persistence. The offline transaction executor has separate leader election for its outbox. Configure each system for the storage it owns.

The browser database uses a worker. Its OPFS setup needs a secure context and browser support for OPFS. Close the database during normal shutdown. After a page returns from the back/forward cache, create fresh database and Collection instances.

Node.js alternative

For a Node.js process, install @tanstack/node-db-sqlite-persistence and better-sqlite3 in place of the browser packages. Open a file with better-sqlite3 and pass it to the Node factory:

ts
import Database from 'better-sqlite3'
import {
  createNodeSQLitePersistence,
  persistedCollectionOptions,
} from '@tanstack/node-db-sqlite-persistence'

const nodeDatabase = new Database('./app.sqlite')
const nodePersistence = createNodeSQLitePersistence({
  database: nodeDatabase,
})

Use nodePersistence in the Collection options shown above. Clean up each Collection before you call nodeDatabase.close(). The Node package README has a complete example.

Schema versions and recovery

By default, a synced Collection resets its persisted data after a schema mismatch. Its sync adapter must then load current data. A Collection without sync reports an error instead. It has no source that can restore deleted local rows.

All runtime factories listed above except the Electron renderer factory accept schemaMismatchPolicy. An explicit reset policy can delete local data, so use it only when your application can restore that data.

The Node package prunes its applied transaction log by default. Its README lists the limits and options. If a resume position is older than the retained log, persistence loads a full snapshot.

Errors and limits

An unavailable browser storage capability raises PersistenceUnavailableError. A durable write failure raises PersistedCollectionDurabilityError and puts the Collection in its error state.

If a coordinated write loses its response, the result can be uncertain. IndeterminateCommitError means the application must check the durable state before it retries that write.

Persistence retains source data and metadata. It does not define how an application sends local changes to a server. Use a mutation handler or the offline transactions guide for that work.