TanStack
Guides

Offline Transactions

@tanstack/offline-transactions gives the leader a durable outbox for pending mutations. The executor retries stored mutations when it can reach the server. The leader restores their optimistic state after a restart.

The outbox stores pending writes. SQLite persistence stores Collection rows. It can also save sync metadata for supported adapters. You can use either package alone or use them together.

Start on the web

Install the package:

shell
npm install @tanstack/offline-transactions

This example starts from an existing todos Collection. The server must accept the same idempotency key on repeated requests.

Keep the collections registry keys and Collection IDs stable across restarts. The executor uses that registry to restore stored mutations.

An action chooses its path when invoked. If executor.isOfflineEnabled is false then, it uses the online-only path without durable retry. If it selects the offline path but loses leadership before its outbox write, the transaction rejects with NonRetriableError and rolls back its optimistic change.

ts
import {
  NonRetriableError,
  startOfflineExecutor,
} from '@tanstack/offline-transactions'

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

const executor = startOfflineExecutor({
  collections: { todos },
  mutationFns: {
    saveTodo: async ({ transaction, idempotencyKey }) => {
      const todo = transaction.mutations[0]?.modified as Todo | undefined
      if (!todo) throw new NonRetriableError('Todo mutation is missing')

      const response = await fetch('/api/todos', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(todo),
      })

      if (response.status === 422) {
        throw new NonRetriableError('The server rejected this todo')
      }
      if (!response.ok) throw new Error('Could not save the todo')

      // Wait for a server read or sync observation here if your app needs it.
    },
  },
})

await executor.waitForInit()

const addTodo = executor.createOfflineAction<Todo>({
  mutationFnName: 'saveTodo',
  onMutate: (todo) => {
    todos.insert(todo)
  },
})

const transaction = addTodo({
  id: crypto.randomUUID(),
  title: 'Buy milk',
  completed: false,
})

// Report final failure. This promise can stay pending offline.
void transaction.when('settled').catch((error) => console.error(error))

onMutate must be synchronous. It applies the optimistic change before the executor writes the outbox entry. If that write fails, the transaction fails. A visible optimistic change alone does not prove durable storage.

When isOfflineEnabled is true, the executor calls the named mutationFn after it records the transaction. A temporary error leaves the entry available for retry. NonRetriableError marks a permanent failure and rolls back optimistic state.

If an outbox phase write or deletion fails after mutationFn returns, the affected transaction rejects with the storage error and the executor stops processing queued work. If mutationFn failed permanently, its caller keeps that provider error while the executor batch reports the storage error. Restart with a fresh executor after storage recovers. A durable phase marker prevents another provider call; if the marker write failed, the provider may be called again.

The executor sends a stable idempotencyKey with each attempt. A server can receive an attempt more than once, especially after a restart or leadership change. Make the server treat repeated keys as one logical mutation.

Wait for the right result

waitForInit() waits for storage setup, leader election, and the initial outbox read. It does not wait for every pending mutation to reach the server.

transaction.when('settled') settles when the offline transaction completes or fails. Successful settlement means the configured mutationFn returned and the storage adapter acknowledged outbox deletion. It proves server confirmation only if that function waits for a server acknowledgement, read-back, or sync observation. If deletion fails, the promise rejects with the storage error. A server must honor the supplied idempotency key because a crash before the executor records provider completion can replay the request.

Do not await transaction.when('settled') before you show an offline page. A pending mutation can keep that promise open until connectivity returns. Use it to update submission status or report a final error.

For a multi-step manual transaction, use createOfflineTransaction:

ts
const offlineTransaction = executor.createOfflineTransaction({
  mutationFnName: 'saveBatch',
  autoCommit: false,
})

offlineTransaction.mutate(() => {
  todos.insert({ id: crypto.randomUUID(), title: 'First', completed: false })
  todos.insert({ id: crypto.randomUUID(), title: 'Second', completed: false })
})

void offlineTransaction.commit().catch((error) => console.error(error))

Register saveBatch in mutationFns before you use this example. commit() can remain pending during an offline period. The executor also offers createOfflineAction for one synchronous optimistic action.

Storage and leadership

On the web, the executor tests IndexedDB first. It uses localStorage if IndexedDB is unavailable. You can pass a storage adapter to choose a different store.

One tab leads outbox processing. The executor prefers Web Locks and then BroadcastChannel for leader election. A follower uses the normal online mutation path without an outbox entry. The same online-only path applies when durable storage is unavailable.

If neither election API exists, the current fallback treats the tab as the leader. It does not coordinate multiple tabs.

Check mode, storageDiagnostic, and isOfflineEnabled after waitForInit(). mode describes storage availability. isOfflineEnabled also requires leadership.

ts
await executor.waitForInit()

console.log(executor.mode)
console.log(executor.storageDiagnostic)
console.log(executor.isOfflineEnabled)

Use onStorageFailure when the built-in web stores are unavailable. Use onLeadershipChange when the UI needs to show which tab owns the outbox. A follower can become the leader after the current leader leaves.

React Native and Expo

Install the package, its network detector, and storage for the example adapter. Copy the AsyncStorageAdapter example into your app before you import it:

shell
npm install @tanstack/offline-transactions @react-native-community/netinfo @react-native-async-storage/async-storage
ts
import { startOfflineExecutor } from '@tanstack/offline-transactions/react-native'
import { AsyncStorageAdapter } from './AsyncStorageAdapter'

const executor = startOfflineExecutor({
  collections: { todos },
  storage: new AsyncStorageAdapter('offline-todos:'),
  mutationFns: { saveTodo },
})

await executor.waitForInit()

The saveTodo function and todos Collection use the same roles as the web example. AsyncStorageAdapter is an application adapter. The package does not export it. A custom adapter must implement get, set, delete, keys, and clear from StorageAdapter.

The executor does not probe a custom storage adapter. If the initial outbox read fails, waitForInit() rejects. Handle that error during app startup.

The React Native entry point uses ReactNativeOnlineDetector by default. It uses NetInfo and app state events. Pass onlineDetector if your app needs another connectivity rule.

Inspect and manage pending work

ts
const entries = await executor.peekOutbox()
const pending = executor.getPendingCount()
const running = executor.getRunningCount()

The counts describe local work. An outbox entry can include a retry count, next attempt time, and last error. None of these values proves that the server applied the mutation.

beforeRetry can remove entries during replay. onUnknownMutationFn reports an entry whose registered function is missing. Keep mutation function names stable across releases, or define a migration for stored entries.

The executor also exposes removeFromOutbox(id) and clearOutbox(). Use these only when the application decides to discard pending writes. Call dispose() when the executor's owner ends.

Use SQLite persistence with the outbox

This React Native recipe stores applied Collection rows in OP-SQLite and pending mutations in AsyncStorage. The Query Collection supplies server sync. Install the packages for both stores and for Query Collection sync:

shell
npm install @tanstack/db @tanstack/query-core @tanstack/query-db-collection \
  @tanstack/react-native-db-sqlite-persistence @op-engineering/op-sqlite \
  @tanstack/offline-transactions @react-native-async-storage/async-storage \
  @react-native-community/netinfo

Copy the AsyncStorage adapter into your app. It implements StorageAdapter. Neither package exports this adapter. Use an absolute server URL that your device can reach.

Keep the SQLite file name, Collection ID, todos registry key, saveTodo name, and outbox prefix stable across restarts. The server must treat repeated Idempotency-Key values as one mutation. The executor can retry a request after the server applies it.

ts
import { open } from '@op-engineering/op-sqlite'
import { QueryClient } from '@tanstack/query-core'
import { createCollection } from '@tanstack/db'
import {
  NonRetriableError,
  startOfflineExecutor,
} from '@tanstack/offline-transactions/react-native'
import { queryCollectionOptions } from '@tanstack/query-db-collection'
import {
  createReactNativeSQLitePersistence,
  persistedCollectionOptions,
} from '@tanstack/react-native-db-sqlite-persistence'
import { AsyncStorageAdapter } from './AsyncStorageAdapter'

type Todo = { id: string; title: string; completed: boolean }
const apiUrl = 'https://api.example.com/todos' // Replace with your server URL.

const database = open({ name: 'todos.sqlite', location: 'default' })
const persistence = createReactNativeSQLitePersistence({ database })
const queryClient = new QueryClient()

const todos = createCollection(
  persistedCollectionOptions<Todo, string>({
    ...queryCollectionOptions<Todo, string>({
      id: 'todos',
      queryClient,
      queryKey: ['todos'],
      queryFn: async () => {
        const response = await fetch(apiUrl)
        if (!response.ok) throw new Error('Could not load todos')
        return (await response.json()) as Array<Todo>
      },
      getKey: (todo) => todo.id,
    }),
    persistence,
    schemaVersion: 1,
  }),
)

const executor = startOfflineExecutor({
  collections: { todos },
  storage: new AsyncStorageAdapter('offline-todos:'),
  mutationFns: {
    saveTodo: async ({ transaction, idempotencyKey }) => {
      const todo = transaction.mutations[0]?.modified as Todo | undefined
      if (!todo) throw new NonRetriableError('Todo mutation is missing')

      const response = await fetch(apiUrl, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(todo),
      })

      if (response.status === 422) {
        throw new NonRetriableError('The server rejected this todo')
      }
      if (!response.ok) throw new Error('Could not save the todo')
      await todos.utils.refetch()
    },
  },
})

await executor.waitForInit()
void todos.preload().catch((error) => console.error(error))

const addTodo = executor.createOfflineAction<Todo>({
  mutationFnName: 'saveTodo',
  onMutate: (todo) => {
    todos.insert(todo)
  },
})

const transaction = addTodo({
  id: crypto.randomUUID(),
  title: 'Buy milk',
  completed: false,
})
void transaction.when('settled').catch((error) => console.error(error))

For the leader, waitForInit() waits for the initial outbox read and optimistic restoration attempt. It does not make todos ready. The separate preload() call starts Collection loading without blocking offline actions. A screen that needs initial rows can await todos.preload() and handle a rejection.

The final refetch() asks the server for current rows after a successful write. By default, it can resolve when that read fails. The offline transaction can then settle successfully because the POST succeeded. That read error does not itself request an outbox retry. Check todos.utils.isError and todos.utils.lastError to report or retry the read.

The React Native offline transactions example shows the full app structure. Its API client does not yet send an idempotency key, so use the request pattern above when you adapt it. The shopping list example also shows Electric sync.

The Electron example uses a custom SQLite-backed StorageAdapter for the outbox. That adapter is separate from the persisted Collection tables.