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.
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.
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.
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.
Related
- Cache Versioning & Migration — the parent guide on persisted snapshots outliving the code that wrote them.
- Migrating Persisted Cache Schemas — the alternative when discarding is not acceptable.
- Gracefully Handling Corrupt Persisted Cache — snapshots that fail for reasons other than age.
- Throttling Cache Writes to Storage — writing the snapshots this buster decides the fate of.