docs-src/docs/articles/tanstack-db/tanstack-query-offline-persistence-upgrade.md
import {Steps} from '@site/src/components/steps';
TanStack Query offline persistence with persistQueryClient saves the dehydrated query cache to a storage like localStorage and restores it on the next page load. For caching server responses across reloads this is often all you need. But a cache snapshot is not a database, and when local data becomes the source of truth of your app, the snapshot model bites back. TanStack DB is the in-memory reactive client store from the same TanStack family, with live queries and optimistic mutations, and it delegates persistence and sync to the collection type you choose. The official @tanstack/rxdb-db-collection package puts RxDB underneath it, as described in TanStack DB + RxDB. This page explains what persistQueryClient does well, where the snapshot model ends, and how to upgrade to durable offline storage with TanStack DB and RxDB.
TanStack Query manages server state. It fetches, caches, deduplicates, and refetches data that lives on your server. The persistence plugin extends this cache across page loads: persistQueryClient restores a previously persisted cache on startup, then subscribes to cache changes and saves the dehydrated cache again, throttled to at most once per second by default. On the next reload the user sees the last known server data instantly instead of a spinner, and TanStack Query refetches in the background when the data is stale.
This is the before state that most apps start with:
import ReactDOM from 'react-dom/client';
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// gcTime must be at least as high as maxAge,
// otherwise garbage collection discards the restored cache.
gcTime: 1000 * 60 * 60 * 24 // 24 hours
}
}
});
// localStorage also fulfills the AsyncStorage interface.
const persister = createAsyncStoragePersister({
storage: window.localStorage
});
// Restores the persisted cache on startup, keeps queries idle until
// the restore is done, then saves the cache on every change (throttled).
ReactDOM.createRoot(document.getElementById('root')!).render(
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 1000 * 60 * 60 * 24 }}
>
<App />
</PersistQueryClientProvider>
);
When your local data is a disposable copy of server data, this setup is enough. A read-mostly dashboard, a product catalog, a news feed: they all profit from a restored cache and lose nothing when it is discarded. There is no reason to add a database to these apps.
persistQueryClient is built around one operation: dehydrate the whole query cache, serialize it with JSON.stringify, and write it under a single storage key (default: REACT_QUERY_OFFLINE_CACHE). Restoring reverses this and hydrates the whole cache back into memory. This design is right for a cache and wrong for a database, for several reasons.
10k documents this write amplification happens on every change, throttled only by the throttleTime option (default: 1000 ms).maxAge (default: 24 hours) is silently discarded on restore. A changed buster string discards it too, which is the intended way to drop the cache on a new deployment. When gcTime is lower than maxAge, garbage collection removes the data even earlier. For a cache this is correct behavior. For user-created data it is data loss.removeOldestQuery retry strategy makes room by throwing away the oldest query. The docs also suggest compressing with lz-string through the serialize and deserialize options, which delays the wall but does not remove it.idb-keyval, which lifts the 5 MiB limit and skips string serialization. Browser quotas for IndexedDB are far higher, from about 1 GB on iOS Safari to a share of free disk space in Chrome. But the persister interface still stores one dehydrated client blob, so reads and writes still move the whole cache, just against a bigger storage.None of this is a flaw in TanStack Query. It is a server-state cache doing cache things. The trouble starts when your app writes data locally, needs it to survive for months, holds more documents than fit in one serialized blob, or needs to query it while offline. At that point you have outgrown offline persistence and need offline storage.
TanStack Query answers the question "what did the server say, and is it still fresh". TanStack DB answers the question "what is the current local state, and which rows match my query". These are different problems, and the two libraries are designed to work together: TanStack DB ships an official Query collection type that loads collection data through TanStack Query. There are also Electric, TrailBase, PowerSync, LocalStorage, LocalOnly, and RxDB collection types, plus TanStack's own SQLite persistence adapters and the @tanstack/offline-transactions package for queueing mutations offline.
The RxDB collection is the right choice when local data is the source of truth, the pattern described in local-first software. RxDB gives the TanStack DB collection what a cache snapshot cannot give:
maxAge, data stays until you delete it.Your components keep the developer experience they had with TanStack Query: hooks that return live data. Only now the data comes out of a database instead of a cache.
The following example is the canonical chain from the hub article: an RxDatabase with a durable storage, wrapped into a TanStack DB collection, queried with useLiveQuery. It uses the free localStorage-based storage for a minimal setup. When your dataset grows past a few MiB, switch to IndexedDB or OPFS. Switching storages is a configuration change, not a rewrite.
npm install rxdb rxjs @tanstack/react-db @tanstack/rxdb-db-collection
import { createRxDatabase } from 'rxdb/plugins/core';
import { getRxStorageLocalstorage } from 'rxdb/plugins/storage-localstorage';
const db = await createRxDatabase({
name: 'appdb',
storage: getRxStorageLocalstorage()
});
await db.addCollections({
todos: {
schema: {
title: 'todos',
version: 0,
type: 'object',
primaryKey: 'id',
properties: {
id: { type: 'string', maxLength: 100 },
title: { type: 'string' },
done: { type: 'boolean' }
},
required: ['id', 'title', 'done']
}
}
});
import { createCollection } from '@tanstack/react-db';
import { rxdbCollectionOptions } from '@tanstack/rxdb-db-collection';
const todosCollection = createCollection(
rxdbCollectionOptions({
rxCollection: db.todos
})
);
The collection loads its initial state from disk and stays in sync with RxDB from then on. There is no restore step to wait for at every startup and no snapshot to expire.
import { useLiveQuery, eq } from '@tanstack/react-db';
function OpenTodos() {
// Live query: re-renders whenever a matching document changes,
// no matter if the change came from this tab, another tab, or sync.
const { data: openTodos } = useLiveQuery((q) =>
q
.from({ todo: todosCollection })
.where(({ todo }) => eq(todo.done, false))
);
return (
<ul>
{openTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
// Writes are optimistic in memory and persisted to RxDB.
todosCollection.insert({ id: 'todo-1', title: 'buy milk', done: false });
todosCollection.update('todo-1', (draft) => {
draft.done = true;
});
Replication is configured on the RxDB collection. TanStack DB does not talk to the backend and picks up pulled documents automatically through RxDB's change feed.
import { replicateRxCollection } from 'rxdb/plugins/replication';
const replicationState = replicateRxCollection({
collection: db.todos,
replicationIdentifier: 'todos-api-sync',
live: true,
pull: {
handler: async (checkpoint, batchSize) => {
// fetch documents changed since the last checkpoint
const response = await fetch(
`/api/pull?checkpoint=${encodeURIComponent(
JSON.stringify(checkpoint)
)}&limit=${batchSize}`
);
return await response.json();
}
},
push: {
handler: async (rows) => {
// send local writes to the backend
const response = await fetch('/api/push', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rows)
});
return await response.json();
}
}
});
The general pattern, including ready-made plugins for GraphQL, CouchDB, and Supabase, is described in How to Sync TanStack DB with Your Backend.
</Steps>Keep persistQueryClient when all of the following are true: the server is the source of truth, the persisted data is small enough for one serialized blob, losing the persisted cache costs nothing but a loading spinner, and offline usage means "show the last response", not "keep working". Many apps live comfortably inside these bounds. You can also run both patterns in one app: TanStack Query with persistence for server-owned reference data, and TanStack DB with RxDB for the documents your users create and edit offline. The full offline pattern is described in Building an Offline-First App with TanStack DB and RxDB.
Yes. The official docs show how to build a custom persister on IndexedDB with idb-keyval, which lifts the localStorage size limit. The persister still stores the whole dehydrated cache as one entry, so it does not become a queryable database. For queryable durable storage, use an RxStorage under TanStack DB instead.
No. It saves a dehydrated snapshot of the query cache and restores it on startup, with a default maxAge of 24 hours after which the snapshot is silently discarded. A database like RxDB stores documents durably, queries them with indexes, and replicates them with a backend.
No. TanStack Query and TanStack DB solve different problems, and TanStack DB even ships a Query collection type that loads data through TanStack Query. You can keep TanStack Query for server-owned data and back the collections that hold local-first user data with RxDB.
</details> <details> <summary>Does the localStorage limit affect TanStack Query offline persistence?</summary>Yes. Browsers limit localStorage to about 5 MiB per origin, and when the serialized cache exceeds the quota, persisting fails. By default there is no retry, and the removeOldestQuery strategy makes room by dropping data. RxDB avoids this wall by writing single documents to storages like IndexedDB, which allow far larger quotas.
No. RxDB is a database, so documents stay on disk until your code deletes them. There is no maxAge and no cache busting. The Sync Engine keeps local documents and your backend consistent instead of discarding and refetching them.