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.


Persist and restore cycle for the query cache Top row shows a runtime write path: the in-memory QueryClient cache passes through a shouldDehydrateQuery filter and a persister into durable storage. Bottom row shows the boot restore path: durable storage is read back, validated against buster and maxAge, and either hydrated into the cache or discarded. WRITE (RUNTIME) RESTORE (BOOT) QueryClient in-memory cache queries + mutations shouldDehydrate drop status:error drop sensitive keys stamp buster Persister serialize JSON throttled write Durable Storage localStorage or IndexedDB Restore + check buster match? age < maxAge? else discard Hydrated cache renders from disk offlineFirst reads Background sync revalidate if stale when online
The runtime write path filters and serializes the cache; the boot path validates buster and maxAge before hydrating, then revalidates in the background once connectivity returns.

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 undefined field crashes.
  • localStorage throws QuotaExceededError once 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 error state 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 dehydrate snapshots the cache to a plain serializable object and hydrate restores it, as detailed in SSR & RSC Hydration Boundaries.
  • gcTime vs staleTime: know that gcTime governs how long an inactive query stays in memory and staleTime governs when it is refetched — persistence depends on gcTime outliving maxAge.
  • 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 (the PersistQueryClientProvider and persistQueryClient), plus one persister — @tanstack/query-sync-storage-persister or @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:

  1. Create a QueryClient with a gcTime that comfortably exceeds your intended maxAge.
  2. Build a persister with createSyncStoragePersister, pointing storage at window.localStorage.
  3. Swap QueryClientProvider for PersistQueryClientProvider, passing persistOptions with the persister, maxAge, and a buster version string.
  4. Add a dehydrateOptions.shouldDehydrateQuery predicate 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.
  • buster is your schema version. Bumping it on a breaking deploy discards the entire persisted cache — cheaper and safer than migrating serialized entities in place.
  • gcTime must be greater than maxAge. If gcTime is shorter, React Query evicts the query from memory before the persister writes it, and it silently never persists.
  • dehydrateOptions.shouldDehydrateQuery is the only filter between your cache and disk. Anything it returns true for is serialized in plaintext, so treat it as a security boundary, not just an optimization.
  • localStorage is synchronous and blocks the main thread; a payload past ~1–2MB produces visible jank on write and risks QuotaExceededError at 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:

  1. Install idb-keyval and adapt its get/set/del to the AsyncStorage interface the persister expects.
  2. Create the persister with createAsyncStoragePersister, passing that adapter as storage.
  3. Mount PersistQueryClientProvider exactly as before — the provider does not care whether the persister is sync or async.
  4. Optionally gate first render on the onSuccess restore 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:

  • createAsyncStoragePersister never blocks paint, but restoration completing after first render means you must design for a brief isRestoring window — read it from useIsRestoring() and hold offline-critical routes until it clears.
  • maxAge here 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.
  • buster invalidation drops the whole IndexedDB payload on mismatch. Because the store can hold far more than localStorage, a careless bump discards a large cache — reserve it for genuine schema breaks.
  • dehydrateOptions.shouldDehydrateQuery filtering out error states 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 a serialize/deserialize codec such as superjson if 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:

  1. Import experimental_createPersister from @tanstack/react-query-persist-client.
  2. Provide a storage adapter (here IndexedDB via idb-keyval) and a maxAge for individual query entries.
  3. Attach the persister as defaultOptions.queries.persister on the QueryClient — no provider swap needed.
  4. 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 maxAge gives independent expiry, but there is no single dehydrateOptions.shouldDehydrateQuery gate — you exclude a query from persistence by not attaching the persister to it, so scope it per query or per default rather than globally when secrets are involved.
  • gcTime still must exceed the per-query maxAge, 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-query version 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.