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 throws QuotaExceededError on write.
  • Large synchronous localStorage writes 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 gcTime was too low.
  • Date fields come back as strings, or Map/Set values vanish entirely after a restore.

idb-keyval persist and restore sequence A query settles and is throttled into idb-keyval, which writes to IndexedDB. On reload, PersistQueryClientProvider reads it back, checks buster and maxAge, and hydrates. A timeline at the bottom shows gcTime spanning longer than maxAge so the query survives long enough to persist. Query settles status: success idb-keyval set throttled 1s async, off-thread IndexedDB object store Restore buster match? age < maxAge? MEMORY LIFETIME maxAge (disk validity) gcTime (in-memory retention) — must span longer than maxAge If gcTime ended before maxAge, the query would be evicted before it could persist.
A settled query is throttled into IndexedDB via idb-keyval and restored on reload; gcTime must outlast maxAge so the query survives in memory long enough to be written.

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.


  • Offline Cache Persistence — the parent reference comparing sync localStorage and async IndexedDB persisters, dehydrateOptions filtering, and networkMode tuning 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.