Cache Versioning & Migration

A persisted cache is the only part of a frontend deployment that can be older than the code reading it. Ship a release that renames a field, and every returning user hydrates a cache full of objects that no longer match their TypeScript types — with no compiler, no test and no error to tell you, just components rendering undefined where a value used to be. This guide belongs to Cache Hydration & Persistence Strategies and is the operational counterpart to Offline Cache Persistence: that guide covers getting data into storage, this one covers what happens when the code changes underneath it.

The decision at the centre of this topic is simple and often made backwards. Most teams should discard a stale persisted cache, not migrate it, because a query cache is an optimisation whose worst-case cost is one round trip. Migration earns its keep only where the cache is the offline datastore.

Diagnostic Checklist

Prerequisites

You should be familiar with the dehydrate and hydrate cycle covered in Hydrating React Query in Next.js App Router, and with persister mechanics from Persisting React Query Cache to IndexedDB. Versioning wraps those two operations; it does not replace them.

Restore-time decision tree for a persisted cache A stored snapshot enters a version check. If the version matches the build it hydrates directly. If the version is older but known, entries pass through a migration chain and entries that fail are dropped individually. If the version is unknown or the payload fails to parse, the whole snapshot is discarded and the application starts with an empty cache. Three outcomes, and only one of them may ever throw stored snapshot written by an older build version check buster + schema exact match — hydrate no work, no risk known older — migrate apply v1 to v2 to v3 in order per entry, inside a try block an entry that throws is dropped the rest of the cache survives unknown or corrupt — discard remove the stored payload start from an empty cache costs one round trip, never a crash Default to the red path Migration is permanent code that must be kept working against shapes you no longer produce. Write it only when empty means broken.
Discarding is not a failure mode — it is the correct outcome for a cache that exists purely to save a request.

Implementation 1 — Version the Payload Explicitly

A snapshot with no version is a snapshot you cannot reason about. Wrap the dehydrated state in an envelope before it reaches storage.

  1. Define a schema version that you bump by hand when cached shapes change.
  2. Record the build identifier separately, for diagnostics rather than compatibility.
  3. Store both alongside the dehydrated payload in one envelope object.
  4. Read the envelope, not the payload, everywhere on the restore path.
import { dehydrate, hydrate, type QueryClient, type DehydratedState } from '@tanstack/react-query';

/** Bump by hand whenever a cached response shape changes. */
export const CACHE_SCHEMA_VERSION = 4;

export interface CacheEnvelope {
  schema: number;
  build: string;
  writtenAt: number;
  state: DehydratedState;
}

export function packCache(client: QueryClient, build: string): CacheEnvelope {
  return {
    schema: CACHE_SCHEMA_VERSION,
    build,
    writtenAt: Date.now(),
    state: dehydrate(client, {
      // An allowlist, not a denylist: a new query is excluded until someone
      // deliberately opts it in.
      shouldDehydrateQuery: (query) => {
        const root = String((query.queryKey as unknown[])[0] ?? '');
        return query.state.status === 'success' && PERSISTED_ROOTS.has(root);
      },
    }),
  };
}

const PERSISTED_ROOTS = new Set(['boards', 'todos', 'users']);

Cache Behavior Analysis: dehydrate walks the QueryCache and serializes each query’s key, hash, data and dataUpdatedAt. Preserving dataUpdatedAt is what makes a restored entry honour staleTime correctly — a snapshot restored an hour later is immediately stale and refetches, exactly as it should, rather than appearing freshly fetched. The shouldDehydrateQuery allowlist matters more than it looks: the default persists every successful query, so a token exchange or a personal-data endpoint lands in localStorage the first time someone calls it. Keying the allowlist on the first key element makes it a property of your key namespaces rather than a list that drifts.

Configuration Trade-offs:

  • A hand-maintained schema version discards far less often than a build hash, at the cost of remembering to bump it. Add a review checklist item; it is a one-line change that a reviewer can check.
  • Storing writtenAt lets you expire snapshots by age independently of version, which is the right tool for data that is merely old rather than incompatible.
  • Recording build is for support, not for logic. Branching on it turns every deploy into a compatibility decision.
Discard, migrate or expire — choosing per data class Matrix mapping four classes of cached data against the recommended versioning strategy, what an empty cache costs, and whether migration code is justified. Discard, migrate or expire — choosing per data class Cost of an empty cac… Strategy Migration code? List and detail queries One round trip on next view Discard on schema bump No Dashboard aggregates A few seconds of skeleton Expire by age, discard on bump No Offline drafts and queued mutations Unsent user work is lost Migrate per entry, never discard Yes — with fixtures Auth and session data Nothing — should not be persisted Exclude from dehydrate entirely Not applicable
Only the offline draft row justifies permanent migration code. Everything else is cheaper to refetch than to maintain.

Implementation 2 — Bust on Deploy With a Build Key

TanStack Query’s persistQueryClient accepts a buster string. Any stored snapshot whose buster differs is dropped before it is read, which is the whole mechanism you need for the discard strategy.

  1. Inject the build identifier at bundle time so it is a constant in the shipped code.
  2. Compose the buster from the build id and the schema version.
  3. Set maxAge to expire snapshots that are merely old.
  4. Remove the persisted client explicitly when the buster changes, so storage does not accumulate.
import { QueryClient } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

// Injected at build time (Vite: import.meta.env; Next: process.env).
const BUILD_ID = import.meta.env.VITE_BUILD_ID ?? 'dev';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000, gcTime: 24 * 60 * 60 * 1000 } },
});

const persister = createSyncStoragePersister({
  storage: window.localStorage,
  key: 'app-cache',
  // Silently drop writes that would exceed quota rather than throwing.
  retry: ({ persistedClient, error }) => {
    if (error instanceof DOMException && error.name === 'QuotaExceededError') return undefined;
    return persistedClient;
  },
});

persistQueryClient({
  queryClient,
  persister,
  // Any snapshot written under a different buster is removed, not read.
  buster: `${BUILD_ID}:${CACHE_SCHEMA_VERSION}`,
  // Independently of version: a snapshot older than a day is not worth restoring.
  maxAge: 24 * 60 * 60 * 1000,
});

Cache Behavior Analysis: persistQueryClient compares the stored buster before deserializing anything, so a mismatched snapshot costs one storage read and is then removed — the incompatible data never reaches hydrate and cannot produce a malformed cache entry. maxAge is checked against the snapshot’s own timestamp, not against per-query dataUpdatedAt, so it is a coarse whole-cache expiry rather than a per-entry one; the per-entry freshness rules still come from staleTime after hydration. Setting gcTime at least as long as maxAge matters: an entry whose gcTime has elapsed is dropped by dehydrate at write time, so a short gcTime with a long maxAge produces snapshots that are mostly empty.

Configuration Trade-offs:

  • A build-hash buster discards on every deploy, including deploys that changed only CSS. Correct and wasteful — acceptable when releases are weekly, painful when they are hourly.
  • Composing build id with schema version gives you both levers: bump the schema for shape changes, and keep the build id if you want deploy-level busting as well.
  • Returning undefined from retry on quota errors means the cache silently stops persisting rather than throwing in a hot path. Log it, or you will never know it happened.

Implementation 3 — Migrate Per Entry, Not Per Cache

When migration is genuinely required, the unit of failure must be one entry. A single malformed record should not cost the user their entire offline dataset.

  1. Keep an ordered list of migrations, each from one version to the next.
  2. Apply them to each dehydrated query independently, inside a try.
  3. Drop entries that throw and count them; keep everything that survives.
  4. Re-stamp the envelope so the next load sees the current version.
type Migration = (data: unknown, queryKey: readonly unknown[]) => unknown;

const MIGRATIONS: Record<number, Migration> = {
  // v1 → v2: the API renamed `owner` to `assignee`.
  2: (data) => {
    if (!isRecord(data)) return data;
    const { owner, ...rest } = data as Record<string, unknown> & { owner?: unknown };
    return owner === undefined ? data : { ...rest, assignee: owner };
  },
  // v2 → v3: timestamps became ISO strings instead of epoch millis.
  3: (data) => {
    if (!isRecord(data) || typeof data.updatedAt !== 'number') return data;
    return { ...data, updatedAt: new Date(data.updatedAt).toISOString() };
  },
  // v3 → v4: lists became { items, total } instead of a bare array.
  4: (data) => (Array.isArray(data) ? { items: data, total: data.length } : data),
};

export function migrateEnvelope(envelope: CacheEnvelope): { state: DehydratedState; dropped: number } {
  let dropped = 0;

  const queries = envelope.state.queries.flatMap((query) => {
    try {
      let data = query.state.data;
      for (let v = envelope.schema + 1; v <= CACHE_SCHEMA_VERSION; v++) {
        const migrate = MIGRATIONS[v];
        // A gap in the chain means we cannot reach the current shape safely.
        if (!migrate) throw new Error(`no migration to schema v${v}`);
        data = migrate(data, query.queryKey as readonly unknown[]);
      }
      return [{ ...query, state: { ...query.state, data } }];
    } catch {
      dropped += 1;
      return []; // this one entry is lost; the rest of the cache is intact
    }
  });

  return { state: { ...envelope.state, queries }, dropped };
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

Cache Behavior Analysis: Migrations run against the dehydrated payload before hydrate, so a malformed entry is never inserted into the live cache and cannot be observed by a component mid-migration. Using flatMap with an empty array for the failure case removes the entry entirely rather than hydrating it as undefined — a query that hydrates with undefined data has status success, which means observers will render an empty state instead of fetching. Because the loop walks versions in order, a snapshot three versions old passes through every intermediate transformation, which is what keeps each migration small enough to reason about.

Configuration Trade-offs:

  • Per-entry isolation costs a try per query. Irrelevant next to the deserialization you already paid for.
  • Migrations that need the query key — because the shape differs per resource — get it as the second argument. Resist that where you can; key-dependent migrations are the ones that rot.
  • Reporting dropped upward gives you a metric worth alerting on. A migration that quietly drops 40% of entries looks identical to one that works.
Entries surviving a three-version migration, by failure isolation Bar chart. Whole-cache try-catch preserves zero entries. Per-query isolation preserves 988. Per-query isolation with a validation pass preserves 988 and rejects the same 12 before they hydrate. Entries surviving a three-version migration, by failure isolation 1,000-entry offline cache with 12 malformed records try around the whole restore 0 kept try around each entry 988 kept try + schema validation per entry 988 kept
The difference between wrapping the loop and wrapping the body is 988 entries of the user's offline data.

Implementation 4 — Fail Into an Empty Cache

Storage can return truncated JSON, a payload from a different application on the same origin, or a value written by a browser extension. The restore path must treat everything it reads as untrusted.

  1. Parse inside a try and treat any throw as “no cache”.
  2. Validate the envelope’s shape before touching its contents.
  3. Remove the stored value when it fails, so the failure does not repeat every load.
  4. Report the discard, then continue — never block boot on a cache problem.
export async function restoreCache(
  client: QueryClient,
  storage: { getItem: (k: string) => string | null; removeItem: (k: string) => void },
  onDiscard: (reason: string) => void,
): Promise<void> {
  const raw = storage.getItem('app-cache');
  if (!raw) return;

  let envelope: CacheEnvelope;
  try {
    const parsed: unknown = JSON.parse(raw);
    if (!isValidEnvelope(parsed)) throw new Error('envelope shape rejected');
    envelope = parsed;
  } catch (error) {
    // Truncated write, foreign payload, or a shape we no longer understand.
    storage.removeItem('app-cache');
    onDiscard(`parse: ${(error as Error).message}`);
    return;
  }

  if (envelope.schema > CACHE_SCHEMA_VERSION) {
    // Written by a NEWER build — a user who was served a canary release and
    // then routed back. We cannot migrate forwards, so drop it.
    storage.removeItem('app-cache');
    onDiscard('snapshot newer than this build');
    return;
  }

  const { state, dropped } = envelope.schema < CACHE_SCHEMA_VERSION
    ? migrateEnvelope(envelope)
    : { state: envelope.state, dropped: 0 };

  hydrate(client, state);
  if (dropped > 0) onDiscard(`migrated with ${dropped} entries dropped`);
}

function isValidEnvelope(value: unknown): value is CacheEnvelope {
  return (
    isRecord(value) &&
    typeof value.schema === 'number' &&
    typeof value.writtenAt === 'number' &&
    isRecord(value.state) &&
    Array.isArray((value.state as { queries?: unknown }).queries)
  );
}

Cache Behavior Analysis: The forward-version check is the case teams forget. Canary deployments and staged rollouts routinely give one user a newer build and then route them back to the previous one; without this branch, the older code reads a payload it has no migration for and hydrates entries in a shape it cannot render. Removing the stored value on every failure path is what stops a corrupt payload from being re-parsed and re-failing on every single page load for the rest of that user’s life. Calling hydrate after migration rather than before means the live cache only ever sees data that has already reached the current shape.

Configuration Trade-offs:

  • Structural validation catches the cheap cases. Full schema validation with a library such as Zod catches more and costs real milliseconds on a large payload — worth it for offline datastores, wasteful for a request-saving cache.
  • Discarding on a forward version loses a canary user’s cache each time they are routed back. That is the safe direction; forward migration is not something you can write in advance.
  • onDiscard should reach your telemetry. A discard rate that jumps after a release is the fastest signal that a shape changed without a schema bump.

Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Returning users crash after a deploy; fresh profiles are fine A persisted snapshot in the old shape hydrated into new code Add a buster composed of build id and schema version — see Busting a Persisted Cache on Deploy
One malformed record wipes the whole offline cache The restore path wrapped the entire loop in a single try Move the try inside the per-entry map — see Migrating Persisted Cache Schemas
The same parse error repeats on every page load The failing payload was never removed from storage Call removeItem on every failure branch — see Gracefully Handling Corrupt Persisted Cache
Sensitive data found in localStorage shouldDehydrateQuery was a denylist and a new query slipped past it Replace with an allowlist of key namespaces
Restored snapshots are almost empty gcTime is shorter than maxAge, so entries are dropped at dehydrate time Set gcTime at least as long as maxAge
Cache stops persisting silently on some devices A QuotaExceededError is being swallowed by the persister retry Log the quota branch and reduce what you persist — see Throttling Cache Writes to Storage

Frequently Asked Questions

Should I migrate a persisted cache or just throw it away on deploy?

Throw it away, unless the cache is load-bearing for offline use. A persisted query cache is an optimisation: discarding it costs the user one round trip on their next view, which they will not notice. Migration code, by contrast, is permanent — it must keep working against data shapes you stopped producing years ago, it needs fixtures for every historical version, and each new migration multiplies the paths through your restore logic. Write it when an empty cache means a broken experience: queued offline mutations, unsynced drafts, or a genuinely offline-first application. Otherwise, bust and refetch.

What exactly should the buster key contain?

A value that changes when the shape of cached data changes. A build hash is the common choice because it is automatic and impossible to forget, at the cost of discarding on every deploy — including releases that only touched styling. A hand-maintained schema version discards far less often and is the better choice for a team that deploys several times a day, provided bumping it is part of code review rather than something people remember. Composing the two, as in Implementation 2, gives you both: bump the schema deliberately, and keep deploy-level busting as a safety net until you trust the discipline.

Why does dehydrate include queries I did not want persisted?

Because the default shouldDehydrateQuery persists every query in success status, and any filter written as a denylist only excludes what someone remembered to name. The failure is quiet — a token exchange or a personal-data endpoint lands in localStorage the first time anyone calls it, and nothing surfaces until a security review. Writing the filter as an allowlist of key namespaces inverts the default: a new query is excluded until someone deliberately opts it in, which is the direction you want a mistake to fall.

How do I test a migration against a cache I no longer produce?

Commit fixtures. Every time you bump the schema version, capture a real dehydrated payload from the previous version and save it into the test suite as JSON, then assert that migrateEnvelope turns it into the current shape with zero drops. This is the only thing that keeps migration code honest once the writer that produced the old shape has been deleted — without fixtures, the v1-to-v2 migration becomes untested code that everyone is afraid to remove. Assert on the drop count too, so a migration that starts quietly discarding entries fails the test rather than the user.