Gracefully Handling Corrupt Persisted Cache
Storage is not a variable. What comes back from it was written by a different build, possibly by a different application on the same origin, possibly by a tab that was killed halfway through the write — and the one thing you can be certain of is that it is not necessarily what you put there. A restore path that assumes otherwise turns a truncated string into an exception during boot, which is the single worst place for one. This page covers surviving that, under Cache Versioning & Migration. It is the third of three: Busting a Persisted Cache on Deploy handles snapshots that are merely old, Migrating Persisted Cache Schemas handles those worth saving, and this one handles everything else.
Diagnostic Checklist
Prerequisites
- A versioned envelope around the persisted state.
- An error reporter that accepts a reason string.
- A restore path you can wrap — either the persister’s, or your own.
Step 1 — Treat Everything From Storage as Untrusted
Parse inside a try, then validate the shape before touching the contents.
import type { DehydratedState } from '@tanstack/react-query';
export interface CacheEnvelope { schema: number; build: string; writtenAt: number; state: DehydratedState }
export type RestoreOutcome =
| { ok: true; envelope: CacheEnvelope }
| { ok: false; reason: string };
export function readEnvelope(raw: string | null): RestoreOutcome {
if (!raw) return { ok: false, reason: 'absent' };
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
// The commonest real cause: a tab killed part-way through a write.
return { ok: false, reason: `parse: ${(error as Error).message.slice(0, 80)}` };
}
if (!isEnvelope(parsed)) return { ok: false, reason: 'envelope-shape' };
return { ok: true, envelope: parsed };
}
function isEnvelope(value: unknown): value is CacheEnvelope {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as Partial<CacheEnvelope>;
return (
typeof candidate.schema === 'number' &&
typeof candidate.writtenAt === 'number' &&
typeof candidate.state === 'object' &&
candidate.state !== null &&
// The one field hydrate genuinely requires.
Array.isArray((candidate.state as { queries?: unknown }).queries)
);
}
Cache Behavior Analysis: Validating the envelope rather than the entries is the right depth for a query cache: hydrate only needs state.queries to be an array, and every entry inside it is refetchable, so a malformed entry costs a refetch while a malformed envelope costs an exception. Truncating the parse error message keeps the eventual report bounded — a SyntaxError from a two-megabyte string can carry a long excerpt, and that excerpt may contain user data. Returning a discriminated outcome rather than throwing keeps the caller’s control flow linear, which matters because this runs on the boot path where an unhandled rejection is a blank page.
Step 2 — Remove the Payload on Every Failure Path
A corrupt value that is left in place fails identically on the next load, and the one after that.
export async function restoreCache(
client: QueryClient,
storage: { getItem: (k: string) => string | null; removeItem: (k: string) => void },
report: (reason: string) => void,
key = 'app-cache',
): Promise<void> {
const outcome = readEnvelope(storage.getItem(key));
if (!outcome.ok) {
if (outcome.reason !== 'absent') {
// Remove BEFORE reporting: a reporter that throws must not leave the
// bad payload behind to fail again on the next load.
storage.removeItem(key);
report(outcome.reason);
}
return;
}
const { envelope } = outcome;
if (envelope.schema > CACHE_SCHEMA_VERSION) {
storage.removeItem(key);
report(`schema-forward:${envelope.schema}`);
return;
}
if (Date.now() - envelope.writtenAt > MAX_AGE_MS) {
storage.removeItem(key);
report('expired');
return;
}
hydrate(client, envelope.state);
}
Cache Behavior Analysis: Removing before reporting matters more than it looks: a reporter that throws — an ad blocker intercepting the telemetry endpoint is a real case — would otherwise abort the function with the corrupt payload still in storage, guaranteeing the same failure on the next load and every load after it. Treating 'absent' as a non-event keeps the discard metric meaningful, since a first-time visitor is not a failure. Removing on expired rather than leaving the stale snapshot means storage does not accumulate payloads that will never be restored, which matters on the devices where quota is tightest.
Step 3 — Never Let Restore Block Boot
Restoring a cache is an optimisation. An optimisation that can prevent the application from starting is a liability.
import { useEffect, useState } from 'react';
export function CacheGate({ children }: { children: React.ReactNode }) {
const [restored, setRestored] = useState(false);
useEffect(() => {
let cancelled = false;
// A hard ceiling: if storage is slow or wedged, render anyway.
const timeout = window.setTimeout(() => { if (!cancelled) setRestored(true); }, 250);
restoreCache(queryClient, window.localStorage, reportDiscard)
.catch((error) => {
// Restore itself failing must never propagate to the boot path.
reportDiscard(`restore-threw: ${(error as Error).message.slice(0, 80)}`);
})
.finally(() => {
if (cancelled) return;
window.clearTimeout(timeout);
setRestored(true);
});
return () => { cancelled = true; window.clearTimeout(timeout); };
}, []);
// Render the shell immediately; an unrestored cache just means a fetch.
if (!restored) return <AppShell />;
return <>{children}</>;
}
Cache Behavior Analysis: The timeout is what converts a wedged IndexedDB — blocked by another tab holding a versionchange transaction, for instance — from a permanently blank page into a normal cold start. Catching inside the promise chain rather than relying on an error boundary keeps the failure off React’s rendering path, where it would unmount the tree it was supposed to accelerate. Rendering the shell rather than a spinner during the restore window means the 250 milliseconds cost nothing perceptible: components mount, their queries start fetching, and a successful restore simply means some of those fetches find fresh data already present.
Step 4 — Report the Discard Rate
A discard rate is a deploy signal. Without it, a schema change that broke restore looks like nothing at all.
const DISCARD_ENDPOINT = '/api/telemetry/cache-restore';
export function reportDiscard(reason: string) {
try {
navigator.sendBeacon(
DISCARD_ENDPOINT,
new Blob(
[JSON.stringify({
reason,
build: BUILD_ID,
schema: CACHE_SCHEMA_VERSION,
// No key contents, no payload excerpt — a reason code only.
})],
{ type: 'application/json' },
),
);
} catch {
// Telemetry must never be the thing that breaks a restore path.
}
}
// Also report the successes, or the rate has no denominator.
export function reportRestoreOk(entryCount: number) {
try {
navigator.sendBeacon(
DISCARD_ENDPOINT,
new Blob([JSON.stringify({ reason: 'ok', build: BUILD_ID, entries: entryCount })], {
type: 'application/json',
}),
);
} catch { /* ignore */ }
}
Cache Behavior Analysis: Reporting successes as well as failures is what turns a count into a rate, and only the rate is interpretable — a hundred discards a day means nothing without knowing whether that is out of two hundred restores or two hundred thousand. Keeping the payload to a reason code and a build id avoids the trap of shipping an excerpt of the corrupt data, which by definition is unvalidated and may contain anything the user had cached. sendBeacon inside a try covers the ad-blocker case; a telemetry failure that propagates would defeat the entire point of the recovery path it is reporting on.
Edge Cases & Gotchas
IndexedDB blocked by another tab. A versionchange transaction held open by another tab blocks open indefinitely. Handle onblocked by resolving rather than waiting, and let the timeout in Step 3 do the rest.
Storage disabled entirely. Some privacy modes throw on localStorage access rather than returning null. Wrap the getItem itself, not just the parse.
A corrupt payload written by your own code. If the discard reason is consistently envelope-shape for one build, the writer and the reader disagree — check that both sides were deployed together, which is a real hazard when the persister lives in a separately versioned package.
Common Pitfalls & Resolutions
| Observable Issue | Root Cause | Diagnostic Resolution |
|---|---|---|
| The same user fails on every single load | The corrupt payload was never removed | Call removeItem on every failure branch |
| A blank page on some devices | Restore threw or hung on the boot path | Wrap in try, add a timeout, render the shell first |
| Parse errors report user data | The exception message included a payload excerpt | Truncate the message before reporting |
| A restore regression shipped unnoticed | Only failures were reported, so there was no rate | Report successes too |
| Telemetry failures break restore | sendBeacon threw and propagated |
Wrap the reporter in its own try |
Frequently Asked Questions
How does a persisted cache actually become corrupt?
Four ways, in rough order of frequency. A tab closed or killed part-way through a synchronous localStorage write leaves truncated JSON. A quota limit truncates a write in the same manner. Another application or a browser extension on the same origin writes to a key that happens to collide with yours — rarer, but it does happen on shared domains. And a schema change produces a payload that is perfectly valid JSON and structurally wrong for the code reading it, which is the one that busting and migration exist to handle. Only the last is preventable by design; the others are simply facts about storage.
Why remove the payload rather than just ignoring it?
Because ignoring it means the same value is read, parsed and rejected on every subsequent page load, forever. The user experiences a one-off write failure as a permanent condition, and since a hard refresh re-reads the same storage, none of the usual advice helps them. Removing makes the corruption cost exactly one degraded load. It is a two-line change and it is the single highest-value part of this entire page.
Is full schema validation worth the cost on restore?
It depends on what a wrong entry costs. For an offline datastore, where the persisted records are the application’s state and a malformed one produces incorrect behaviour rather than a refetch, full validation with a schema library is worth the milliseconds. For a query result cache, a structural check of the envelope is enough: every entry inside it is refetchable, so the worst case for a bad one is that a component shows a loading state. Paying to deep-validate a few megabytes on every cold start, to avoid a refetch, is the wrong trade.
Related
- Cache Versioning & Migration — the parent guide on persisted state outliving its code.
- Busting a Persisted Cache on Deploy — handling snapshots that are merely incompatible.
- Migrating Persisted Cache Schemas — the path for data worth saving rather than discarding.
- Throttling Cache Writes to Storage — reducing the writes that produce truncated payloads in the first place.