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.
Every failure has the same ending Four failure classes on the restore path — unreadable storage, unparseable JSON, an envelope that fails structural validation, and a schema newer than the running build — all lead to the same outcome: remove the stored payload, report the reason, and continue booting with an empty cache. Four ways to fail, one recovery storage unreadable private mode, quota JSON will not parse write truncated envelope rejected foreign payload schema is newer canary rollback removeItem → report(reason) → boot with an empty cache costs one round trip on the next view; never throws, never blocks Skipping the removal is what turns a one-off corruption into a permanent failure for that user.
There is no failure class here worth a distinct recovery. Distinguishing them matters only for the report.

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.

Loads affected by one corrupt snapshot per user Bar chart of failing page loads per affected user. Leaving the payload in place produced 214 failing loads. Removing it on failure produced 1. Removing plus reporting produced 1 and surfaced the cause within an hour. Loads affected by one corrupt snapshot per user Measured over 30 days for the affected population payload left in place 214 loads removed on failure 1 loads removed + reported 1 loads
The corruption happens once. Whether it becomes 214 failures is entirely a property of the recovery path.

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.

Reason codes and what each one usually means Matrix of five restore failure reasons with the typical cause and whether a spike is actionable. Reason codes and what each one usually means Typical cause Spike means parse A tab killed mid-write, or a truncated quota-limited write Writes are too large, or too frequent envelope-shape Another writer on the same origin, or a changed envelope A deploy changed the envelope without a buster schema-forward A canary user routed back to an older build Rollout percentages, not a bug expired The snapshot is older than maxAge Users returning less often than maxAge allows restore-threw Storage wedged, or an unexpected exception Investigate immediately — this one should be zero
A steady low rate of parse failures is normal. A step change in envelope-shape or schema-forward is a deploy problem.

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.