Cache Hydration & Persistence Strategies

Server-state cache is expensive to build and easy to lose. Every time a user reloads the page, navigates from a server-rendered route to a client route, or opens the app offline, the in-memory query cache is at risk of evaporating — forcing a fresh network round-trip for data the browser already fetched seconds ago. Getting that cache to survive across boundaries — from the server render into the client, and from one browser session into the next — is the engineering domain this guide covers. It matters most for SaaS platform teams running SSR or React Server Components alongside offline or flaky-network requirements with React Query, Apollo, SWR, or RTK Query.

This guide is for frontend platform engineers who own the boundary where a request-scoped server cache is serialized, shipped to the browser, and either merged into a fresh client cache or written to durable storage. It covers the two disciplines that define that boundary: SSR and streaming hydration boundaries — how the server prefetches data, dehydrates it, and how the client rehydrates without a mismatch — and offline cache persistence — how the running cache is written to localStorage or IndexedDB and restored on the next visit.

Both disciplines depend on the same primitives: a serializable snapshot of the cache, a deterministic identity for each entry, and rules for when restored data is trusted versus refetched. Teams that already have clean query key design and normalization in place find hydration far easier, because a serialization boundary is only as reliable as the keys that survive it. Hydration is also tightly coupled to cache invalidation and server synchronization: restored data is stale data by definition, so the rules that govern staleness are exactly the rules that govern whether a rehydrated entry is shown, refetched, or discarded.

Architectural Overview

The diagram below models the full lifecycle of a cache entry as it crosses two boundaries: the network boundary (server render to client) and the storage boundary (memory to disk and back). A query is prefetched on the server, dehydrated into serialized JSON embedded in the HTML payload, hydrated into a fresh client QueryClient, and then — separately — persisted to browser storage from which it is rehydrated on the next visit.

Cache hydration and persistence lifecycle across the network and storage boundaries A query is prefetched into a per-request server QueryClient, dehydrated into serialized state embedded in the HTML, hydrated into a fresh client QueryClient via HydrationBoundary, then persisted to browser storage and rehydrated on the next page load. Server Render per-request QueryClient prefetchQuery() Dehydrated State dehydrate() → JSON embedded in HTML Client Hydration HydrationBoundary hydrate() merges into fresh client cache Live Client Cache QueryClient in memory staleTime governs refetch persist (throttled) Persisted Storage localStorage / IndexedDB maxAge · buster · throttleTime serialized snapshot Rehydration next visit / offline restore if buster + maxAge valid restore into fresh client cache Network boundary: server → client (dehydrate/hydrate) · Storage boundary: memory → disk (persist/restore)
The cache crosses two boundaries. The dehydrate/hydrate path moves a request-scoped server cache into a fresh client cache; the persist/restore path moves the live client cache to durable storage and back on the next visit.

Core Concepts Reference

Concept Definition React Query API Apollo Client API SWR / RTK Query API
dehydrate / hydrate Serialize a cache to a plain object, then merge it back into another cache dehydrate(queryClient) / hydrate(client, state) cache.extract() / cache.restore(data) SWR fallback map / RTK extractRehydrationInfo
HydrationBoundary Component that injects dehydrated state into the client cache during render <HydrationBoundary state={…}> ApolloProvider + initialState SWRConfig value.fallback / RTK <Provider>
Persister Adapter that writes the cache snapshot to durable storage and restores it persistQueryClient / experimental_createPersister apollo3-cache-persist SWR provider cache + custom sync / redux-persist
Storage adapter The concrete backing store the persister reads and writes createSyncStoragePersister / createAsyncStoragePersister LocalStorageWrapper / IndexedDBWrapper localStorage map / storage engine
staleTime on hydration Whether restored data is fresh or refetches immediately on mount staleTime (ms) on query/defaults fetchPolicy: 'cache-first' dedupingInterval / keepUnusedDataFor
Serialization boundary The point where cache state must become JSON-safe (no functions, promises, class instances) dehydrateOptions.shouldDehydrateQuery cache.extract() JSON output fallback JSON / serializableCheck

Strategy 1 — SSR & Streaming Hydration Boundaries

The first boundary is the network. On the server you create a request-scoped QueryClient, prefetch the data a route needs, and serialize the result so the client can pick it up without refetching. In the Next.js App Router with TanStack Query v5, this is the dehydrateHydrationBoundaryhydrate pipeline, and the details of the App Router integration are where most teams stumble.

The non-negotiable rule is one QueryClient per request. A module-level singleton on the server would share dehydrated state across concurrent requests and leak one user’s data into another user’s HTML. The standard pattern wraps a getQueryClient factory in React’s cache() so all server components in a single render tree share one instance, while every request still gets a fresh one. On the client you do the opposite — create the client exactly once and keep it stable across re-renders so hydration merges into a persistent cache rather than a new one on every render.

Streaming changes the timing but not the shape. With React Server Components and <Suspense>, prefetched queries can be dehydrated incrementally as each boundary resolves; TanStack Query’s streaming hydration streams individual dehydrated queries into the client cache as the server flushes them, rather than waiting for the entire tree. The mental model stays the same: prefetch, dehydrate, hydrate — the most common failure is a hydration mismatch when a query rendered above the fold was not prefetched, so the server renders a loading state while the client renders data.

Apollo takes a different path: getDataFromTree (or the newer streaming SSR utilities) walks the tree, executes queries, and cache.extract() produces the serializable snapshot restored on the client with cache.restore(). SWR uses a fallback map keyed by cache key passed through SWRConfig, and RTK Query rehydrates via extractRehydrationInfo on the API slice. The primitive is identical across all four: a serializable snapshot keyed by cache identity.

Configuration trade-offs:

  • staleTime must be set above 0 on dehydrated queries (or via defaultOptions.queries.staleTime); otherwise every hydrated query is stale on arrival and refetches on mount, defeating the point of server prefetching.
  • dehydrateOptions.shouldDehydrateQuery controls which queries are serialized into the payload — by default only successful queries are included, but you can widen it to dehydrate pending queries for streaming SSR, which increases HTML payload size.
  • A per-request server QueryClient must never be a module singleton; use cache(() => new QueryClient()) so each request is isolated while a single render shares one instance.
  • Setting gcTime too low on the server client is harmless (it is discarded after the response), but on the client a low gcTime evicts hydrated data before the user interacts, causing an immediate refetch.

Strategy 2 — Persisting Cache to Browser Storage

The second boundary is storage. Once the cache is live in memory, offline cache persistence writes a serialized snapshot to localStorage or IndexedDB so the next visit — or an offline launch — starts from warm data instead of empty. TanStack Query v5 offers two mechanisms: the older whole-cache persistQueryClient (with createSyncStoragePersister for localStorage or createAsyncStoragePersister for async stores) and the newer per-query experimental_createPersister, which persists each query independently and restores it lazily on first subscribe.

The storage adapter choice is a real engineering decision, not a detail. localStorage is synchronous and capped at roughly 5–10 MB per origin; every write blocks the main thread, so it is only appropriate for small caches. IndexedDB is asynchronous, holds far more, and stores structured and binary data — the correct choice for feed and dashboard apps that cache hundreds of queries. Persisting the React Query cache to IndexedDB via idb-keyval and an async persister avoids the main-thread jank that localStorage causes at scale.

Other libraries have their own persistence stacks. Apollo uses apollo3-cache-persist, which snapshots the InMemoryCache to a storage wrapper (LocalStorageWrapper or IndexedDBWrapper) and restores it before the first render. SWR persists through its provider cache — you pass a Map-backed provider to SWRConfig and sync it to storage yourself, since SWR has no built-in persister. RTK Query typically rides on redux-persist, persisting the whole store including API cache slices. In every case the persisted snapshot must survive schema drift, which is what buster and maxAge exist to guarantee.

Configuration trade-offs:

  • maxAge (default 24 hours) sets how old a persisted cache may be before the whole snapshot is discarded on restore — shorten it for volatile data, lengthen it for reference data that changes rarely.
  • buster should be a build hash or schema version; bump it in CI whenever a DTO shape, query key structure, or normalization format changes so incompatible persisted data is discarded rather than hydrated.
  • throttleTime (default 1000 ms) debounces writes to storage — raising it reduces write pressure and main-thread cost for localStorage but widens the window in which recent cache changes are lost on a crash.
  • experimental_createPersister persists per query with its own maxAge, so hot queries stay fresh while cold ones expire independently — but it does not restore data until a query is subscribed, so there is no synchronous whole-cache warm start.

Strategy 3 — Rehydration Correctness & Staleness

Restoring a snapshot is easy; restoring it correctly is where production bugs live. A rehydrated query carries its original dataUpdatedAt timestamp, so whether it displays instantly or triggers a network refetch is decided entirely by staleTime. With the default staleTime: 0, every restored query is stale the moment it mounts and refetches immediately — which is almost never what a persistence layer is meant to achieve. Setting a deliberate staleTime lets restored data render without a round-trip until it genuinely ages out, and this interacts directly with cache invalidation and server synchronization: rehydrated data is stale data, and the same freshness rules apply.

Two failure classes dominate. The first is stale schema: persisted data from a previous deploy is structurally incompatible with the current code, and without a buster it hydrates into components that expect a new shape and crash. The second is cross-tab divergence: two tabs each hold their own in-memory cache, both write to the same storage key, and a write in one tab silently overwrites the other’s snapshot. TanStack Query’s broadcastQueryClient mitigates this by broadcasting cache updates across tabs over a BroadcastChannel, keeping their in-memory caches in sync so the persisted snapshot is consistent regardless of which tab flushes it.

Offline adds a temporal dimension. A cache persisted while offline may hold optimistic mutations that never reached the server; on reconnect those must be replayed or reconciled, which is why offline persistence is usually paired with a mutation queue. TanStack Query’s networkMode and paused-mutation resume behavior, combined with syncing offline mutations on reconnect, close this loop. The persistence layer restores reads; the mutation queue reconciles writes.

Configuration trade-offs:

  • staleTime on restored queries must be non-zero to avoid an immediate refetch on mount; balance it against how stale the UI may safely appear before a background revalidation.
  • buster mismatches discard the whole persisted cache — safe but blunt; per-query experimental_createPersister lets you expire selectively instead of losing everything on a single schema bump.
  • gcTime must be greater than or equal to the persister’s maxAge, or queries are garbage-collected out of memory before the persister can write them, leaving gaps in the restored snapshot.
  • broadcastQueryClient keeps cross-tab caches consistent but adds a BroadcastChannel dependency; without it, the last tab to flush wins and can clobber another tab’s newer data.

Production Code: App Router Prefetch + HydrationBoundary + Persistence

The snippet below wires the full pipeline in a Next.js App Router / TanStack Query v5 setup: a per-request server QueryClient factory, a server component that prefetches and dehydrates, a HydrationBoundary that hands the state to the client, and a client provider that attaches an IndexedDB persister with maxAge, buster, and cross-tab sync.

// lib/getQueryClient.ts — per-request client on the server, singleton on the client
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query';
import { cache } from 'react';

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      // Non-zero staleTime so hydrated + restored queries render without an
      // immediate refetch on mount. 60s is a safe default for most reads.
      queries: { staleTime: 60_000, gcTime: 24 * 60 * 60 * 1000 },
      dehydrate: {
        // Include pending queries too, so streaming SSR can flush in-flight data.
        shouldDehydrateQuery: (query) =>
          defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
      },
    },
  });
}

// cache() gives every server request its own client while sharing one instance
// across all server components in a single render. Never a module-level singleton.
export const getQueryClient = cache(makeQueryClient);

// app/dashboard/page.tsx — server component: prefetch, then dehydrate
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/getQueryClient';
import { DashboardClient } from './DashboardClient';
import { projectKeys, fetchProjects } from '@/features/projects';

export default async function DashboardPage() {
  const queryClient = getQueryClient();

  // Prefetch runs on the server and writes into the request-scoped cache.
  // await ensures the data is present before we dehydrate below.
  await queryClient.prefetchQuery({
    queryKey: projectKeys.list(),
    queryFn: fetchProjects,
  });

  // dehydrate() serializes the request cache into JSON-safe state that
  // HydrationBoundary embeds in the HTML and merges into the client cache.
  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <DashboardClient />
    </HydrationBoundary>
  );
}

// app/providers.tsx — client provider with IndexedDB persistence + cross-tab sync
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { broadcastQueryClient } from '@tanstack/query-broadcast-client-experimental';
import { get, set, del } from 'idb-keyval';
import { useEffect, useState } from 'react';
import { getQueryClient } from '@/lib/getQueryClient';

export function Providers({ children }: { children: React.ReactNode }) {
  // useState initializer creates the client exactly once on the client so
  // hydration merges into a stable, persistent cache across re-renders.
  const [queryClient] = useState(() => getQueryClient());

  useEffect(() => {
    // Async persister backed by IndexedDB (via idb-keyval) — writes are
    // off the main thread, unlike a synchronous localStorage persister.
    const persister = createAsyncStoragePersister({
      storage: {
        getItem: (key) => get(key),
        setItem: (key, value) => set(key, value),
        removeItem: (key) => del(key),
      },
      throttleTime: 1000, // debounce writes to storage to reduce IO pressure
    });

    const [unsubscribe] = persistQueryClient({
      queryClient,
      persister,
      maxAge: 24 * 60 * 60 * 1000, // discard snapshots older than 24h on restore
      buster: process.env.NEXT_PUBLIC_BUILD_HASH, // bump on schema change to drop stale data
    });

    // Keep every tab's in-memory cache consistent so the persisted snapshot
    // is never clobbered by a stale write from another tab.
    broadcastQueryClient({ queryClient, broadcastChannel: 'app-cache' });

    return () => unsubscribe();
  }, [queryClient]);

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

Cache Behavior Impact. The server prefetchQuery populates a request-isolated cache; dehydrate turns it into JSON that HydrationBoundary merges into the client cache during the first render, so no refetch fires on mount because staleTime is 60_000. Separately, persistQueryClient snapshots that same client cache to IndexedDB through the async persister — maxAge and buster decide whether the snapshot is restored on the next visit, and broadcastQueryClient keeps concurrent tabs from overwriting each other’s persisted state. The two boundaries share one QueryClient but are governed by independent policies.

Common Engineering Pitfalls

Symptom Root Cause Resolution
React logs a hydration mismatch and the first paint flickers from loading to data A query rendered above the fold was not prefetched on the server, so the server rendered a loading state while the client already had dehydrated data Prefetch every above-the-fold query with queryClient.prefetchQuery and wrap the subtree in a HydrationBoundary; move non-deterministic values out of render into useEffect
One user briefly sees another user’s data after a deploy A module-level QueryClient singleton on the server shared dehydrated state across concurrent requests Create the server client with cache(() => new QueryClient()) so each request is isolated; only the client may hold a long-lived instance
Restored cache crashes components with “cannot read property of undefined” after a release Persisted data from a previous schema hydrated into code that expects a new shape Set buster to a build hash and bump it in CI whenever a DTO, query key, or normalization format changes so incompatible snapshots are discarded
Every persisted query refetches immediately on load, defeating the cache staleTime is 0, so restored data with its original dataUpdatedAt is stale the instant it mounts Set a non-zero staleTime (via defaultOptions.queries.staleTime) so restored data stays fresh for a window after its original fetch
Two open tabs keep overwriting each other’s cached data Each tab holds an independent in-memory cache and both write the same storage key Add broadcastQueryClient so cache updates propagate across tabs over a BroadcastChannel before the persister flushes
The app janks and input lag spikes while cached data is being saved A synchronous localStorage persister blocks the main thread on every write of a large cache Switch to createAsyncStoragePersister backed by IndexedDB and raise throttleTime to batch writes off the critical path

Frequently Asked Questions

Why do I get a hydration mismatch error even though my server and client fetch the same data?

A hydration mismatch happens when the HTML React renders on the client during its first pass differs from the server-rendered markup. With TanStack Query the usual cause is a query that is not prefetched on the server: the server renders a loading state, the client immediately has dehydrated data available, and the two trees diverge. Ensure every query rendered above the fold is prefetched with queryClient.prefetchQuery on the server and wrapped in a HydrationBoundary. The second common cause is non-deterministic values like Date.now() or Math.random() inside render — move those into useEffect so they only run after hydration, when the client tree is allowed to differ from the server tree.

Should I persist the React Query cache to localStorage or IndexedDB?

Use localStorage via createSyncStoragePersister only for small caches — roughly under 2–3 MB — because it is synchronous and blocks the main thread on every write, and browsers cap it at 5–10 MB per origin. Use IndexedDB via createAsyncStoragePersister or experimental_createPersister with an idb-keyval adapter for larger caches, binary payloads, or when you want per-query granular persistence with independent maxAge. IndexedDB writes are asynchronous and do not block rendering, which matters for feeds and dashboards that hold hundreds of cached queries where a synchronous serialize-and-write would produce visible input lag.

How do I invalidate a persisted cache when I deploy a breaking schema change?

Set the buster option on the persister to a build hash or schema version string. When the persisted buster value does not match the current one, persistQueryClient discards the entire restored cache instead of hydrating stale, structurally incompatible data into components that expect a new shape. Pair this with maxAge (default 24 hours) so caches also expire on time, and bump the buster in your CI pipeline whenever a DTO shape, query key structure, or normalization format changes. For finer control, experimental_createPersister expires per query by its own maxAge so you can drop only the affected queries rather than the whole snapshot.

Do I need one QueryClient per request on the server, and why?

Yes. On the server you must create a fresh QueryClient for every request — never a module-level singleton — because a shared client would leak one user’s dehydrated data into another user’s response under concurrent load. The standard App Router pattern wraps a getQueryClient factory in React’s cache() so all server components in a single render share one instance while each request gets its own. On the client you do the opposite: create the QueryClient once inside a useState initializer (or a guarded module singleton) so hydration merges into a stable cache rather than a new one on every render.

Why does my persisted query refetch immediately on load even though it was just restored?

Restored queries carry their original dataUpdatedAt timestamp. If staleTime is 0 (the default), the query is considered stale the instant it is rehydrated and TanStack Query refetches on mount, wasting the persisted data. To serve restored data without an immediate network round-trip, set a non-zero staleTime so the data stays fresh for a window measured from its original fetch time. Keep the two knobs distinct in your head: the persister’s maxAge governs whether the snapshot is restored at all, while staleTime governs whether the restored data then triggers a background refetch.