Offline Cache Persistence
An in-memory query cache dies the instant the tab reloads. For a progressive web app, a mobile-wrapped shell, or any client that must render meaningful data before the network answers, that is unacceptable — the user sees a spinner on every cold boot and a blank screen the moment connectivity drops. Offline cache persistence closes that gap by serializing the TanStack Query v5 cache to durable storage and restoring it on the next launch, so the app paints from disk first and reconciles with the server second. This is the client-durability counterpart to the server-to-client transfer covered in SSR & RSC Hydration Boundaries: both move a dehydrated cache across a boundary, but here the boundary is time and a page reload rather than the network.
This guide covers the concrete decisions: choosing between createSyncStoragePersister on localStorage and createAsyncStoragePersister on IndexedDB, wiring PersistQueryClientProvider, versioning the payload with buster, expiring it with maxAge, filtering it with dehydrateOptions.shouldDehydrateQuery, and configuring networkMode: 'offlineFirst' so reads succeed without connectivity. The same primitives exist in other libraries — Apollo Client ships apollo3-cache-persist and SWR exposes a custom provider cache — and we note where their semantics diverge.
Diagnostic Checklist
You need offline cache persistence — or you have configured it incorrectly — if you observe any of the following:
- Every cold reload shows a loading spinner even though the same data was on screen seconds ago before the refresh.
- Closing and reopening the PWA on a train (no signal) renders a blank screen instead of the last-known data.
- A deploy that changed an entity’s fields hydrates old-shaped objects into new components, producing
undefinedfield crashes. localStoragethrowsQuotaExceededErroronce the cache grows past a few megabytes of feed or list data.- Auth tokens or user PII are visible in the browser’s Application → Local Storage panel because the whole cache is serialized indiscriminately.
- A query that failed with a network error is restored from disk in an
errorstate and never recovers on the next launch. - The restored cache immediately refetches every query on mount, defeating the point of persistence and hammering the API on boot.
Prerequisites
Before wiring persistence, you should be comfortable with:
- Dehydrate / hydrate mechanics: understand that
dehydratesnapshots the cache to a plain serializable object andhydraterestores it, as detailed in SSR & RSC Hydration Boundaries. gcTimevsstaleTime: know thatgcTimegoverns how long an inactive query stays in memory andstaleTimegoverns when it is refetched — persistence depends ongcTimeoutlivingmaxAge.networkMode: familiarity with how'online','always', and'offlineFirst'change whether a query attempts a fetch when the browser reports no connection.- The persist packages:
@tanstack/react-query-persist-client(thePersistQueryClientProviderandpersistQueryClient), plus one persister —@tanstack/query-sync-storage-persisteror@tanstack/query-async-storage-persister.
Implementation 1 — Synchronous localStorage Persistence
The fastest path to a persisted cache is createSyncStoragePersister over window.localStorage. It reads and writes synchronously, so restoration is complete before the first paint, and there is no async gate to manage. It is the correct default for small caches: user preferences, a dashboard summary, a handful of list endpoints.
Steps:
- Create a
QueryClientwith agcTimethat comfortably exceeds your intendedmaxAge. - Build a persister with
createSyncStoragePersister, pointingstorageatwindow.localStorage. - Swap
QueryClientProviderforPersistQueryClientProvider, passingpersistOptionswith the persister,maxAge, and abusterversion string. - Add a
dehydrateOptions.shouldDehydrateQuerypredicate so errored and sensitive queries never reach disk.
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
// gcTime MUST exceed maxAge or queries are evicted before they can persist
gcTime: 1000 * 60 * 60 * 24, // 24h
networkMode: 'offlineFirst',
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
key: 'FC_QUERY_CACHE',
throttleTime: 1000, // coalesce rapid cache writes into one serialize per second
});
export function App({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 12, // discard anything on disk older than 12h
buster: 'v3-2026-06-28', // bump to invalidate the whole persisted cache
dehydrateOptions: {
shouldDehydrateQuery: (query) =>
query.state.status === 'success' &&
!query.queryKey.includes('auth') &&
!query.queryKey.includes('session'),
},
}}
>
{children}
</PersistQueryClientProvider>
);
}
Cache Behavior Impact. PersistQueryClientProvider subscribes to the QueryCache and, on every mutation of cache state, hands a dehydrated snapshot to the persister — throttleTime collapses a burst of writes into a single localStorage.setItem per second so a fast-scrolling list does not serialize on every keystroke of activity. On mount it synchronously reads the key back, compares the embedded buster and the entry age against maxAge, and either calls hydrate or throws the payload away. Because shouldDehydrateQuery returns false for error states and auth/session keys, a failed request is never written and secrets never land in localStorage.
Configuration Trade-offs:
maxAge(12h here) caps staleness of restored data; anything older is discarded wholesale on boot. Set it below the window where your data would become dangerously wrong offline, not merely stale.busteris your schema version. Bumping it on a breaking deploy discards the entire persisted cache — cheaper and safer than migrating serialized entities in place.gcTimemust be greater thanmaxAge. IfgcTimeis shorter, React Query evicts the query from memory before the persister writes it, and it silently never persists.dehydrateOptions.shouldDehydrateQueryis the only filter between your cache and disk. Anything it returnstruefor is serialized in plaintext, so treat it as a security boundary, not just an optimization.localStorageis synchronous and blocks the main thread; a payload past ~1–2MB produces visible jank on write and risksQuotaExceededErrorat the ~5MB origin cap. That ceiling is the trigger to move to Implementation 2.
Implementation 2 — Asynchronous IndexedDB Persistence
Once the serialized cache outgrows localStorage, move to createAsyncStoragePersister backed by IndexedDB through the tiny idb-keyval library. IndexedDB writes are asynchronous and off the main thread, and its quota is measured in hundreds of megabytes, so large feeds, image metadata, and offline document sets fit comfortably.
Steps:
- Install
idb-keyvaland adapt itsget/set/delto theAsyncStorageinterface the persister expects. - Create the persister with
createAsyncStoragePersister, passing that adapter asstorage. - Mount
PersistQueryClientProviderexactly as before — the provider does not care whether the persister is sync or async. - Optionally gate first render on the
onSuccessrestore callback so you never flash empty state before hydration completes.
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days in memory, well above maxAge
networkMode: 'offlineFirst',
},
},
});
// Adapt idb-keyval to the AsyncStorage shape the persister consumes
const idbStorage = {
getItem: (key: string) => get(key),
setItem: (key: string, value: string) => set(key, value),
removeItem: (key: string) => del(key),
};
const persister = createAsyncStoragePersister({
storage: idbStorage,
key: 'FC_QUERY_CACHE_IDB',
throttleTime: 1000,
});
export function App({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24 * 3, // 3 days
buster: 'idb-v2',
dehydrateOptions: {
shouldDehydrateQuery: (query) => query.state.status !== 'error',
},
}}
onSuccess={() => {
// Restoration is complete; safe to resume paused offline mutations
queryClient.resumePausedMutations();
}}
>
{children}
</PersistQueryClientProvider>
);
}
Cache Behavior Impact. With an async persister, restoration is a promise, so PersistQueryClientProvider holds queries in a restoring state until getItem resolves — children still render, but persisted queries do not fetch until hydration settles, preventing a network refetch from racing the disk read. Each idb-keyval set opens a transaction against the object store off the main thread, so a multi-megabyte serialize does not block scrolling. The onSuccess callback fires after hydration, which is the correct moment to call resumePausedMutations so writes queued while offline replay in order once the cache is whole.
Configuration Trade-offs:
createAsyncStoragePersisternever blocks paint, but restoration completing after first render means you must design for a briefisRestoringwindow — read it fromuseIsRestoring()and hold offline-critical routes until it clears.maxAgehere is 3 days; IndexedDB’s generous quota tempts you to raise it, but stale-by-days data can be worse than absent data for anything transactional. Cap it deliberately.busterinvalidation drops the whole IndexedDB payload on mismatch. Because the store can hold far more thanlocalStorage, a careless bump discards a large cache — reserve it for genuine schema breaks.dehydrateOptions.shouldDehydrateQueryfiltering outerrorstates matters more with IndexedDB, because a poisoned large cache persists for days rather than until the next small overwrite.- Non-JSON values (
Date,Map,BigInt, class instances) do not survive the default JSON serialization; pair the persister with aserialize/deserializecodec such assuperjsonif your entities carry them.
Implementation 3 — Experimental Per-Query Persistence
persistQueryClient and its provider persist the entire cache as one blob. When you only need a handful of expensive queries on disk — and want each to expire independently — experimental_createPersister attaches a persister at the query level instead, writing one entry per query key.
Steps:
- Import
experimental_createPersisterfrom@tanstack/react-query-persist-client. - Provide a storage adapter (here IndexedDB via
idb-keyval) and amaxAgefor individual query entries. - Attach the persister as
defaultOptions.queries.persisteron theQueryClient— no provider swap needed. - Let each query serialize on settle and restore itself on next mount.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { experimental_createPersister } from '@tanstack/react-query-persist-client';
import { get, set, del } from 'idb-keyval';
const persister = experimental_createPersister({
storage: {
getItem: (key) => get(key),
setItem: (key, value) => set(key, value),
removeItem: (key) => del(key),
},
maxAge: 1000 * 60 * 60 * 24, // per-query, 24h
buster: 'per-query-v1',
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 48, // outlive the per-query maxAge
networkMode: 'offlineFirst',
persister,
},
},
});
export function App({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
Cache Behavior Impact. Unlike the whole-cache provider, experimental_createPersister restores a query’s data lazily on first mount and writes it back when the query settles, so the boot cost scales with the queries actually rendered rather than the full cache size. Each entry carries its own buster and maxAge, so one query expiring or failing its version check does not discard the others — the failure blast radius is a single key instead of the entire persisted store.
Configuration Trade-offs:
- Per-query
maxAgegives independent expiry, but there is no singledehydrateOptions.shouldDehydrateQuerygate — you exclude a query from persistence by not attaching thepersisterto it, so scope it per query or per default rather than globally when secrets are involved. gcTimestill must exceed the per-querymaxAge, or the query is garbage-collected from memory before it can write.- Because restoration is lazy and per-mount, a query not yet mounted contributes nothing offline — this is ideal for on-demand detail views but wrong for a dashboard that must be whole on boot; use Implementation 1 or 2 there.
- The API is prefixed
experimental_and its surface can shift between minor versions; pin your@tanstack/react-queryversion if you depend on it.
Notes for Apollo and SWR
Apollo Client persists through apollo3-cache-persist, which calls persistCache({ cache, storage }) against a LocalStorageWrapper or IndexedDB adapter and versions the payload through its own SchemaVersion mechanism rather than a buster string. SWR has no built-in disk layer; you supply a custom provider returning a Map seeded from localStorage and flush it on beforeunload, which gives restore-on-boot but not the throttled incremental writes or maxAge expiry that persistQueryClient provides.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Persisted queries never appear on disk despite activity | gcTime is shorter than maxAge, so React Query evicts the query from memory before the throttled persister writes it |
Set gcTime strictly greater than maxAge in defaultOptions.queries; verify the key exists in Application → Storage after throttleTime elapses |
QuotaExceededError thrown on cache write |
The serialized cache exceeded localStorage’s ~5MB origin quota |
Move to createAsyncStoragePersister on IndexedDB (Implementation 2), or narrow shouldDehydrateQuery to persist fewer query keys |
| Old-shaped entities crash new components after a deploy | The persisted payload predates a schema change and hydrated stale objects into new code | Bump buster to a new version string on every breaking entity or query-key change so the old cache is discarded rather than hydrated |
| Auth token visible in localStorage | shouldDehydrateQuery returned true for auth/session query keys |
Exclude sensitive keys in dehydrateOptions.shouldDehydrateQuery; treat the predicate as a security boundary and never persist tokens or PII |
| App refetches everything on boot despite persistence | Restored dataUpdatedAt is older than staleTime, so queries mount stale and refetch |
Raise staleTime on persisted queries and set networkMode: 'offlineFirst' so cached data renders before any network attempt |
| An errored query hydrates in a permanent error state | The failed query was serialized because shouldDehydrateQuery did not filter status === 'error' |
Return false from shouldDehydrateQuery for query.state.status === 'error' so failures never persist |
Frequently Asked Questions
Should I use localStorage or IndexedDB to persist the React Query cache?
Use createSyncStoragePersister with localStorage for small caches — a few hundred KB — where synchronous access on boot is fine and simplicity matters. Switch to createAsyncStoragePersister backed by IndexedDB once the serialized cache approaches localStorage’s ~5MB per-origin quota, or when large synchronous writes cause visible main-thread jank. IndexedDB writes are asynchronous and its quota runs to hundreds of MB, at the cost of a brief isRestoring window before hydration completes.
How do I stop errored or sensitive queries from being written to disk?
Pass a dehydrateOptions.shouldDehydrateQuery predicate to persistOptions. Return false for any query whose state.status is 'error', and false for query keys holding auth tokens, PII, or per-request data. Only queries the predicate approves are serialized, so a failed request never poisons the restored cache and secrets never touch localStorage or IndexedDB. It is the single filter between your in-memory cache and durable storage, so treat it as a security control.
What is the buster option and when must I change it?
buster is a cache version string embedded in the persisted payload. On restore, persistQueryClient compares the stored buster against the current one and discards the entire persisted cache on a mismatch instead of hydrating it. Bump it whenever an entity shape changes, a query-key structure changes, or a deploy would make cached data invalid. It is far cheaper and safer than attempting to migrate serialized entities in place.
Why does my persisted cache refetch everything the moment the app reopens?
Restoration rehydrates each query with its original dataUpdatedAt timestamp. If that timestamp is older than the query’s staleTime, React Query marks it stale and refetches on mount — so a cache restored from yesterday looks stale immediately. For a true offline-first boot, raise staleTime on persisted queries and set networkMode: 'offlineFirst' so cached data renders instantly and the network is consulted only when it is actually reachable.
Related
- Cache Hydration & Persistence Strategies — the parent section covering how a dehydrated cache moves across server, client, and reload boundaries, and where persistence fits among those transfers.
- SSR & RSC Hydration Boundaries — the server-to-client counterpart to on-disk persistence, using
dehydrate,HydrationBoundary, andprefetchQueryto avoid refetch waterfalls on first paint. - Persisting the React Query Cache to IndexedDB — the step-by-step recipe for
createAsyncStoragePersisteroveridb-keyval, including thegcTime>maxAgerule and serialization edge cases. - Hydrating React Query in the Next.js App Router — how server components prefetch and dehydrate into a client
HydrationBoundary, the pattern you combine with persistence for apps that are both server-rendered and offline-capable. - Syncing Offline Mutations on Reconnect — how paused mutations queued while offline replay once connectivity and cache restoration complete, the write-path complement to persisting reads.