Busting a Persisted Cache on Deploy

A persisted cache is the only artefact in a frontend deploy that can be older than the code reading it. Rename a field, and every returning user hydrates objects that no longer match their types — no compiler error, no failed request, just components rendering undefined where a value used to be, on exactly the users who visit most often. This page covers discarding those snapshots reliably, under Cache Versioning & Migration. Discarding is the right default; when the cache is genuinely load-bearing, Migrating Persisted Cache Schemas covers the alternative, and Gracefully Handling Corrupt Persisted Cache covers snapshots that are not merely old.

Prerequisites

  • A persister in place — see Persisting React Query Cache to IndexedDB.
  • A build pipeline that can inject an environment variable.
  • Agreement on what “the cache is optional” means for your application.
Three buster outcomes A stored snapshot's buster is compared with the running build's. An exact match restores it. A different buster removes it without deserializing. A snapshot whose schema is newer than the running build is also removed, because there is no forward migration. The comparison happens before anything is deserialized stored snapshot buster "a91c:4" compare running "a91c:4" match — hydrate restored, maxAge still applies differs — removed never parsed, never hydrated schema newer — removed canary rollback; no forward migration Cost of a wrong discard: one round trip. Cost of a wrong restore: undefined fields in production. The asymmetry is why the default should always be to discard.
Because the buster is checked before deserializing, an incompatible snapshot cannot produce a malformed cache entry at all.

Step 1 — Inject a Build Identifier

The identifier has to be a constant in the shipped bundle, not something computed at runtime.

// vite.config.ts
import { defineConfig } from 'vite';
import { execSync } from 'node:child_process';

const commit = process.env.VITE_BUILD_ID
  ?? execSync('git rev-parse --short HEAD').toString().trim();

export default defineConfig({
  define: {
    // Replaced at build time, so it is a literal in the output.
    __BUILD_ID__: JSON.stringify(commit),
  },
});

// app/version.ts
declare const __BUILD_ID__: string;

/** Bump by hand when a cached response shape changes. */
export const CACHE_SCHEMA_VERSION = 4;
export const BUILD_ID = typeof __BUILD_ID__ === 'string' ? __BUILD_ID__ : 'dev';

Cache Behavior Analysis: Using define rather than reading an environment variable at runtime means the value is inlined and cannot be undefined in the browser — an identifier that resolves to undefined produces the buster string "undefined:4", which is stable and therefore never busts, which is the worst possible failure for this mechanism. Falling back to 'dev' keeps local development from busting on every hot reload, since the constant is absent there. The commit hash is the natural choice because it changes exactly when the code does, which is the property a build-derived buster needs.


Step 2 — Compose the Buster

Two levers: the build id busts on every deploy, the schema version busts only when shapes change. Composing them gives both.

import { QueryClient } from '@tanstack/react-query';
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000,
      // gcTime must be at least maxAge, or dehydrate drops entries before
      // they are ever written and the snapshot is mostly empty.
      gcTime: 24 * 60 * 60 * 1000,
    },
  },
});

const persister = createAsyncStoragePersister({
  storage: idbStorage,
  key: 'app-cache',
});

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

Cache Behavior Analysis: persistQueryClient reads the stored buster and compares it 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 entry. Including the build id means a deploy that changed only styling still discards, which is wasteful but never wrong; the schema version is what lets you stop doing that once bumping it is part of code review. Setting gcTime at least as long as maxAge is the detail that catches people out: dehydrate skips entries whose gcTime has elapsed, so a five-minute gcTime with a one-day maxAge persists almost nothing.

Snapshots discarded over 30 deploys Bar chart of discard events. A build-hash buster discarded on all 30 deploys. A schema version discarded on 4. A composed buster discarded on 30 but with the schema version available as the intended long-term lever. No buster discarded on none, and produced 4 incidents. Snapshots discarded over 30 deploys A team deploying roughly once a day; 4 of the 30 changed a cached shape build hash only 30 discards schema version only 4 discards composed (build + schema) 30 discards no buster — 4 incidents 0 discards
The last bar is the one to avoid: four deploys where returning users hydrated shapes the code no longer understood.

Step 3 — Expire by Age as Well

Version tells you whether a snapshot is compatible. Age tells you whether it is worth restoring.

persistQueryClient({
  queryClient,
  persister,
  buster: `${BUILD_ID}:${CACHE_SCHEMA_VERSION}`,
  // A snapshot older than a day restores data that will be stale on arrival
  // for almost every query, so restoring it buys a render and costs a refetch.
  maxAge: 24 * 60 * 60 * 1000,

  // Fine-grained control over what is worth writing in the first place.
  dehydrateOptions: {
    shouldDehydrateQuery: (query) => {
      if (query.state.status !== 'success') return false;
      const root = String((query.queryKey as unknown[])[0] ?? '');
      // An allowlist: a new query is excluded until someone opts it in.
      return PERSISTED_ROOTS.has(root);
    },
  },
});

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

Cache Behavior Analysis: maxAge is compared against the snapshot’s own timestamp rather than each entry’s dataUpdatedAt, so it is a whole-cache expiry rather than a per-entry one — after restore, per-entry freshness still comes from staleTime, and a restored entry that is past its staleTime refetches immediately, which is exactly right. The allowlist in shouldDehydrateQuery is a security control as much as a size control: the default persists every successful query, so a token exchange or a personal-data endpoint lands in storage the first time anyone calls it. Writing the filter as an allowlist rather than a denylist means a query added next year is excluded until someone decides otherwise.


Step 4 — Handle a Snapshot From a Newer Build

Canary rollouts and staged rollbacks routinely give one user a newer build and then route them back. There is no forward migration, so this case must discard.

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

export async function restoreWithGuards(
  client: QueryClient,
  persister: { restoreClient: () => Promise<PersistedClient | undefined>; removeClient: () => Promise<void> },
  report: (reason: string) => void,
) {
  const persisted = await persister.restoreClient();
  if (!persisted) return;

  const envelope = persisted.clientState as unknown as Partial<CacheEnvelope>;
  const schema = typeof envelope.schema === 'number' ? envelope.schema : 0;

  if (schema > CACHE_SCHEMA_VERSION) {
    // Written by a build we are older than. We cannot know what changed.
    await persister.removeClient();
    report(`snapshot schema ${schema} is newer than ${CACHE_SCHEMA_VERSION}`);
    return;
  }

  if (schema < CACHE_SCHEMA_VERSION) {
    // Older, known version: either migrate or discard, per your policy.
    await persister.removeClient();
    report(`snapshot schema ${schema} predates ${CACHE_SCHEMA_VERSION}`);
    return;
  }

  hydrate(client, persisted.clientState);
}

Cache Behavior Analysis: The forward-version branch is the one teams forget, because it only occurs during a rollback and never in normal forward deployment — so it ships untested and surfaces during an incident, when the last thing you want is a second failure. Removing rather than ignoring is important: leaving the snapshot in place means it is re-read and re-rejected on every page load for as long as that user stays on the older build. Reporting the reason gives you the metric that matters during a rollback, because a spike in forward-version discards tells you how much of your traffic actually reached the canary.

Which lever to move, and when Matrix of four change types with the buster component that should change and whether users lose their cache. Which lever to move, and when Lever Cache lost? A cached response shape changed Bump the schema version Yes — and it must be A query key structure changed Bump the schema version Yes — old keys address nothing Styling or copy changed Nothing No — with a schema-only buster A rollback to an older build Handled by the forward-version guard Yes, for canary users only
Only the first two rows need to invalidate anything. A build-hash buster cannot tell them apart, which is its whole weakness.

Edge Cases & Gotchas

Service worker caching the bundle. A user on a cached bundle keeps their old BUILD_ID and therefore keeps their snapshot, which is consistent — the old code and old data belong together. Problems start when the service worker updates the bundle without reloading; make sure activation triggers a reload.

Mutation queues must not share the buster. Queued offline mutations represent user work that cannot be refetched. Persist them under their own key with their own version, and migrate rather than bust.

Several tabs, one buster. A deploy while two tabs are open means the newly loaded tab busts a snapshot the old tab is still writing. Give persistence to a leader, per Electing a Leader Tab for Refetching.


Common Pitfalls & Resolutions

Observable Issue Root Cause Diagnostic Resolution
Returning users crash after a deploy An old snapshot hydrated into new code Add a buster containing the schema version
The cache is never restored, ever The build id resolves to undefined, changing the buster every load Inline the constant with define and assert it is a string
Restored snapshots are almost empty gcTime is shorter than maxAge, so entries are dropped at write time Set gcTime at least as long as maxAge
The same snapshot is rejected on every load It was ignored rather than removed Call removeClient() on every rejection path
Offline mutations disappear after a deploy They share the persisted client with the query cache Persist the mutation queue separately

Frequently Asked Questions

Should the buster be the build hash or a hand-maintained version?

Start with both composed, and move toward the schema version alone as the discipline settles. A build hash is automatic and impossible to forget, which is exactly what you want on day one — but it discards on every deploy, including the ones that only changed a stylesheet, so a team shipping several times a day is throwing away every user’s cache several times a day. A hand-maintained schema version discards only when shapes actually change, which is usually a handful of times a year, but it depends on someone remembering to bump it. Making that bump a code-review checklist item is a small price for the difference.

What happens to a user who is served a canary build and then routed back?

They hold a snapshot written under a schema version newer than the code now reading it, and there is no forward migration — you cannot write, in advance, the transformation from a future shape to the current one. The only safe response is to discard, which costs that user one refetch. What makes this worth handling explicitly is that it never occurs during normal forward deployment, so the branch ships untested and first executes during a rollback, which is precisely when you do not want a second surprise.

Does busting lose queued offline mutations?

It does if they are persisted as part of the same client, which is a good reason not to do that. A query result is an optimisation — discarding it costs a round trip. A queued mutation is user work that exists nowhere else, so discarding it loses something the user believes they saved. Persist the mutation queue under its own storage key with its own schema version, and treat it as the case where migration genuinely earns its keep, as covered in Migrating Persisted Cache Schemas.