Persisting the React Query Cache to IndexedDB
A cache that lives only in memory is gone the moment the tab reloads, and localStorage runs out of room the moment your feed grows past a few megabytes. IndexedDB solves both: it persists across reloads and offline sessions, its writes are asynchronous and off the main thread, and its quota is measured in hundreds of megabytes rather than the single-digit MB cap that trips localStorage. This page is the concrete recipe for wiring the TanStack Query v5 cache to IndexedDB using createAsyncStoragePersister over the tiny idb-keyval library. If your app is also server-rendered, pair this with Hydrating React Query in the Next.js App Router, which restores a server cache into the client before this disk layer takes over.
The moving parts are small but order-sensitive: an adapter from idb-keyval to the persister’s storage interface, a persister carrying maxAge and buster, a PersistQueryClientProvider wrapping the tree, and one non-negotiable invariant — gcTime must exceed maxAge, or queries evaporate from memory before they are ever written.
Diagnostic Checklist
Verify your situation matches this recipe before wiring it in:
- Your serialized cache exceeds
localStorage’s ~5MB quota and throwsQuotaExceededErroron write. - Large synchronous
localStoragewrites cause visible jank while a list is scrolling. - Reopening the app offline shows a blank screen because nothing was persisted across the reload.
- You persist successfully but restored queries are missing after a while — they were garbage-collected before serialization because
gcTimewas too low. Datefields come back as strings, orMap/Setvalues vanish entirely after a restore.
Step-by-Step Implementation
Step 1 — Adapt idb-keyval to the AsyncStorage interface
createAsyncStoragePersister expects a storage object with getItem, setItem, and removeItem. idb-keyval exposes get, set, and del, so a three-line adapter bridges them.
// lib/idb-storage.ts
import { get, set, del } from 'idb-keyval';
import type { AsyncStorage } from '@tanstack/query-async-storage-persister';
export const idbStorage: AsyncStorage<string> = {
getItem: (key) => get<string>(key).then((v) => v ?? null),
setItem: (key, value) => set(key, value),
removeItem: (key) => del(key),
};
Cache Behavior Analysis. Each set opens an IndexedDB transaction against the default keyval-store object store on a background thread, so serializing a multi-megabyte cache never blocks the main thread the way a synchronous localStorage.setItem would. Returning null (not undefined) from getItem on a miss matters: the persister treats null as “nothing stored” and boots with an empty cache rather than attempting to hydrate garbage.
Step 2 — Create the async persister with maxAge and buster
The persister owns the serialization codec, the key under which the blob is stored, and the write throttle.
// lib/persister.ts
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { idbStorage } from './idb-storage';
export const persister = createAsyncStoragePersister({
storage: idbStorage,
key: 'FC_RQ_CACHE',
throttleTime: 1000, // coalesce bursts of cache writes into one serialize per second
});
// Version string embedded in the payload; bump on any breaking schema change
export const CACHE_BUSTER = 'schema-2026-06-28';
// Discard anything on disk older than this on restore
export const CACHE_MAX_AGE = 1000 * 60 * 60 * 24 * 3; // 3 days
Cache Behavior Analysis. throttleTime means a fast sequence of cache mutations — a paginated list appending pages, several mutations settling — produces a single IndexedDB write per second instead of one per change, which keeps write amplification low on a large store. The key scopes the entire dehydrated cache to one IndexedDB entry, so restoration is a single get rather than a scan; buster and the entry’s timestamp travel inside that blob and are checked before any hydration occurs.
Step 3 — Wrap the app in PersistQueryClientProvider
Swap QueryClientProvider for PersistQueryClientProvider and hand it persistOptions. Set gcTime above maxAge on the client, and prefer networkMode: 'offlineFirst' so restored data renders before any network attempt.
// app/providers.tsx
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { persister, CACHE_BUSTER, CACHE_MAX_AGE } from '../lib/persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
// gcTime MUST exceed CACHE_MAX_AGE or queries are evicted before they persist
gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days
networkMode: 'offlineFirst',
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: CACHE_MAX_AGE,
buster: CACHE_BUSTER,
dehydrateOptions: {
// never persist failed requests or secrets
shouldDehydrateQuery: (query) =>
query.state.status === 'success' && !query.queryKey.includes('auth'),
},
}}
onSuccess={() => {
// hydration finished — replay any mutations queued while offline
queryClient.resumePausedMutations();
}}
>
{children}
</PersistQueryClientProvider>
);
}
Cache Behavior Analysis. Because the persister is asynchronous, PersistQueryClientProvider keeps persisted queries in a restoring state until getItem resolves — children render immediately, but those queries do not fire a fetch until hydration settles, so a network refetch never races the disk read and overwrites it. The onSuccess callback fires exactly once, after hydration, which is the correct moment to call resumePausedMutations so writes captured offline replay in order against a fully restored cache — the same reconnect flow described in Syncing Offline Mutations on Reconnect.
Step 4 — Gate offline-critical UI on restoration
For routes that must show restored data on first paint, read the restoring flag and hold rendering until it clears. Everything else can render immediately.
// components/OfflineGate.tsx
import { useIsRestoring } from '@tanstack/react-query';
export function OfflineGate({ children }: { children: React.ReactNode }) {
const isRestoring = useIsRestoring();
if (isRestoring) return <p>Restoring your offline data…</p>;
return <>{children}</>;
}
Cache Behavior Analysis. useIsRestoring returns true only for the async window between mount and the persister resolving. Gating a dashboard on it prevents a flash of empty state followed by a pop-in when IndexedDB responds a few milliseconds later; leaving non-critical UI ungated keeps the app interactive while the restore is in flight.
Edge Cases and Gotchas
gcTime shorter than maxAge silently drops queries
This is the single most common failure. gcTime governs how long an inactive query survives in memory; if it is smaller than maxAge, React Query garbage-collects the query before the throttled persister writes it, so it never reaches IndexedDB and never restores. Always set gcTime comfortably above maxAge:
// maxAge 3 days → gcTime must exceed it
gcTime: 1000 * 60 * 60 * 24 * 7, // 7 days, safely above the 3-day maxAge
buster invalidates an old schema on deploy
When you ship a change to an entity’s shape or a query key’s structure, old serialized data would hydrate into new code and crash on missing fields. Bumping buster makes PersistQueryClientProvider discard the entire IndexedDB payload on mismatch instead of hydrating it:
// before: 'schema-2026-06-28' → after a breaking change:
export const CACHE_BUSTER = 'schema-2026-07-05';
The whole persisted cache is dropped and rebuilt from the network on the first post-deploy boot — cheaper and safer than migrating serialized entities in place.
Serialization of non-JSON values
createAsyncStoragePersister serializes with JSON.stringify by default. Date becomes an ISO string, and Map, Set, and BigInt are dropped entirely. If your entities carry those, pass a codec so they round-trip losslessly:
import superjson from 'superjson';
export const persister = createAsyncStoragePersister({
storage: idbStorage,
key: 'FC_RQ_CACHE',
serialize: (client) => superjson.stringify(client),
deserialize: (cached) => superjson.parse(cached),
});
Common Pitfalls and Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| Queries never appear in IndexedDB despite activity | gcTime is smaller than maxAge, so queries are evicted from memory before the throttled persister writes them |
Set gcTime strictly greater than maxAge; confirm the entry exists in Application → IndexedDB after throttleTime |
| Old-shaped entities crash components after a deploy | The persisted payload predates a schema change and hydrated stale objects | Bump buster to a new string on every breaking entity or query-key change so the old cache is discarded, not hydrated |
Date fields become strings or Map values vanish after restore |
Default JSON.stringify serialization does not preserve non-JSON types |
Pass a serialize/deserialize codec such as superjson to the persister |
| A network refetch overwrites restored data on boot | Query fired before async hydration completed | Rely on the provider’s restoring gate and set networkMode: 'offlineFirst'; gate critical UI with useIsRestoring |
Frequently Asked Questions
Why must gcTime be larger than maxAge when persisting to IndexedDB?
gcTime controls how long an inactive query stays in the in-memory cache. If gcTime is smaller than maxAge, React Query garbage-collects the query from memory before the throttled persister can write it to IndexedDB, so it silently never persists and never restores. Setting gcTime comfortably above maxAge keeps the query resident long enough to be serialized on the next throttle tick.
How do I persist Date, Map, or BigInt values that serialization drops?
createAsyncStoragePersister serializes with JSON.stringify by default, which turns Date into a string and drops Map, Set, and BigInt entirely. Pass a serialize/deserialize codec such as superjson to the persister so those types round-trip losslessly, or normalize entities to plain JSON before they enter the cache so there is nothing lossy to serialize.
Do I need PersistQueryClientProvider or can I call persistQueryClient directly?
PersistQueryClientProvider is the React-native path: it subscribes, restores on mount, and exposes an isRestoring state through useIsRestoring. persistQueryClient is the imperative equivalent for non-React or manual bootstraps — it wires up the subscription and returns a restore promise you await yourself. Inside a React tree, prefer the provider because it manages the restoring gate for you rather than making you coordinate it by hand.
Related
- Offline Cache Persistence — the parent reference comparing sync
localStorageand async IndexedDB persisters,dehydrateOptionsfiltering, andnetworkModetuning across the full persistence surface. - Hydrating React Query in the Next.js App Router — the server-to-client hydration recipe you combine with disk persistence for apps that are both server-rendered and offline-capable.
- Syncing Offline Mutations on Reconnect — how mutations paused while offline replay once the cache is restored and connectivity returns, the write-path complement to persisting reads.
- Cache Hydration & Persistence Strategies — the foundational section on moving a dehydrated cache across server, client, and reload boundaries that underpins every persistence decision here.