Debugging Storage Cleared by Safari ITP

A support pattern keeps recurring: users on iPhone and Mac report being silently logged out, or their offline notes vanishing, roughly a week after their last visit — while Chrome and Android users never complain. Your IndexedDB database is suddenly empty, localStorage returns null for keys you know you wrote, and there is no error in the console because nothing failed. This is almost always Safari’s Intelligent Tracking Prevention clearing script-writable storage on its privacy timer, not a bug in your code. This guide sits under Storage Partitioning & Privacy Controls and walks through reproducing the wipe, confirming the root cause, and shipping a fix that makes the wipe a non-event rather than a logout.

ITP 7-day storage clearing timeline and recovery A timeline showing script-writable storage being capped and wiped after seven idle days, with a server-side refresh re-hydrating the client in one request. Day 0: visit token + data written Idle days 1–7 cap counting down Day 7+: wipe script storage cleared Server refresh recovers HttpOnly cookie re-hydrates in one request

Root-cause analysis

Intelligent Tracking Prevention enforces a cap on the lifetime of script-writable storage for origins the user has not adopted as an installed application. The storage it targets is anything a page writes through JavaScript: localStorage, sessionStorage, IndexedDB, the Cache API, and cookies set via document.cookie. Server-set cookies delivered through a Set-Cookie response header are outside this cap, which is the key to the fix. The trigger is inactivity, not disk pressure: when an origin classified as having insufficient user interaction goes roughly seven days without a top-level visit, Safari deletes that origin’s script-writable storage. Extended inactivity escalates to a fuller wipe.

The reason it looks like a phantom bug is that nothing in your code path errors. After the wipe, localStorage.getItem('token') simply returns null and indexedDB.open() succeeds against an empty database, exactly as it would for a brand-new user. If your bootstrap logic interprets a missing token as “logged out” and a missing database as “first run,” the user is silently bounced to a login screen or shown an empty app. The same dynamic underlies quota-driven clearing covered in Debugging Storage Eviction in Progressive Web Apps, but ITP’s clock is a privacy timer the user’s inactivity drives, not a response to a full disk.

Two factors decide whether a given user is affected. First, engagement: Safari tracks whether the user interacts with your origin as a first-party destination, and frequent direct visits or a permission grant raise durability. Second, installation: a PWA added to the Home Screen runs in a storage context that is far more durable than the in-browser tab. The users who complain are overwhelmingly tab-only visitors with sparse, weekly-or-less usage — precisely the profile ITP is designed to time out.

Step-by-step diagnosis

  1. Reproduce in Safari with the Web Inspector. On macOS, open your site in Safari, sign in, then open Develop ▸ Show Web Inspector ▸ Storage tab and confirm your localStorage keys and IndexedDB database are present. For iOS, connect the device and inspect it from Safari on a Mac via the Develop menu. Seeing the data present is your baseline.

  2. Force the clearing condition. Waiting seven idle days is impractical, so simulate it. In the Web Inspector Storage tab you can manually clear the origin’s localStorage and IndexedDB to mimic the post-wipe state, then reload and observe how your app behaves with empty storage. The goal is not to prove ITP fired but to verify your bootstrap handles empty storage gracefully.

  3. Confirm it is inactivity-based, not a code bug. Check three things: the data was present and is now wholly gone (not corrupted or partially written); the affected users are on Safari or iOS specifically, not Chrome/Firefox; and the loss correlates with a gap in usage rather than an app update. If all three hold, you are looking at ITP, not a serialization or transaction fault.

  4. Instrument cold starts. Add telemetry that fires when the app boots, finds storage it expected to be populated empty, and the user was previously active. A spike in this event among Safari users — clustered around multi-day gaps — is the signature of ITP clearing at scale.

The fix: make a wipe recoverable

The durable fix is two-part: request persistence at the right moment to reduce how often the wipe happens, and architect a server-side recovery so that when it does happen, the client re-hydrates in a single request instead of logging the user out. The runnable module below implements both.

// itp-resilience.ts — request durability, detect wipes, recover from the server.

interface SessionState {
  userId: string;
  displayName: string;
  expiresAt: number; // epoch ms
}

const HINT_KEY = 'session-hint';

// 1. Request persistence AFTER the user shows engagement (sign-in, save), not
//    on first paint where Safari is most likely to refuse it.
async function requestDurableStorage(): Promise<boolean> {
  if (!navigator.storage?.persist) return false;
  if (await navigator.storage.persisted()) return true;
  try {
    return await navigator.storage.persist();
  } catch {
    return false;
  }
}

// 2. Detect the wiped state: a hint we expected is gone.
function readSessionHint(): SessionState | null {
  try {
    const raw = localStorage.getItem(HINT_KEY);
    return raw ? (JSON.parse(raw) as SessionState) : null;
  } catch {
    return null;
  }
}

// 3. Recover from the HttpOnly refresh cookie, which ITP's script cap does not
//    touch because it was set server-side, not via document.cookie.
async function recoverSession(): Promise<SessionState | null> {
  const res = await fetch('/auth/session', {
    method: 'POST',
    credentials: 'include', // sends the HttpOnly refresh cookie
  });
  if (!res.ok) return null; // Genuinely signed out — show login.

  const fresh = (await res.json()) as SessionState;
  try {
    localStorage.setItem(HINT_KEY, JSON.stringify(fresh)); // Re-seed the hint.
  } catch {
    /* storage may be unavailable; the in-memory session still works */
  }
  return fresh;
}

// 4. Bootstrap: treat a missing hint as "needs recovery", never as "logged out".
export async function bootstrapSession(): Promise<SessionState | null> {
  const hint = readSessionHint();
  const looksUsable = hint !== null && hint.expiresAt > Date.now();

  // Even with a usable-looking hint, reconcile with the server so a wiped or
  // stale client converges to the truth in exactly one round-trip.
  const session = await recoverSession();
  if (session) {
    await requestDurableStorage(); // User is engaged now — good time to ask.
  }
  return session ?? (looksUsable ? hint : null);
}

The pivot is step 3. Because the refresh credential lives in an HttpOnly, Secure cookie set by your server, it survives ITP’s script-writable cap, and a single /auth/session request restores the user transparently. The local session-hint is treated as a disposable optimization for rendering a logged-in shell instantly — never as the source of truth. This is the same separation argued in Securing Auth Tokens in Browser Storage: durable secrets stay out of script reach, and script storage holds only what you can afford to lose.

For offline data, the equivalent move is to make the local IndexedDB cache a re-fetchable mirror of server state rather than the only copy. After a wipe, the app refills the cache from the server on next connection; the user sees a brief loading state, not data loss. Encouraging Add to Home Screen for offline-critical workflows further reduces the wipe frequency, since installed PWAs receive the durable storage context.

Verification

After deploying, confirm the fix three ways. First, check durability programmatically: await navigator.storage.persisted() should return true for engaged and installed users, and you can surface this in a diagnostics view.

async function reportDurability(): Promise<void> {
  const persisted = await navigator.storage?.persisted?.() ?? false;
  const { usage = 0, quota = 0 } = (await navigator.storage?.estimate?.()) ?? {};
  console.info(`persisted=${persisted} usage=${usage} quota=${quota}`);
}

Second, simulate the wipe by clearing the origin’s storage in the Safari Web Inspector and reloading — the app must recover the session through /auth/session and render the logged-in state without bouncing to login. Third, watch your storage-wiped-cold-start telemetry after release: the event may still fire (the wipe still happens), but the matched logout/login-screen events should collapse toward zero, proving recovery is silent.

Edge cases & fallback

When the server-side recovery itself fails — the refresh cookie has genuinely expired, or the device is offline at cold start — fall back to graceful re-authentication rather than a hard error. Show the cached display name from the hint with a “session expired, please sign in” prompt so the experience reads as a routine timeout, not a crash. If the device is offline and storage was wiped, present read-only cached content where possible and queue a recovery for when connectivity returns, coordinating background work through the patterns in Offline Sync Strategies & Background Workflows.

A second fallback is periodic re-engagement. Because ITP’s clock resets on top-level interaction, a legitimately scheduled re-engagement — a meaningful push notification or an email that brings the user back as a first-party visit within the window — keeps active users above the inactivity threshold. Use this sparingly and only for genuine value; it is a side benefit of real engagement, not a mechanism to defeat the privacy timer, which Safari can and does tighten.

Frequently Asked Questions

Does Safari ITP delete HttpOnly cookies too?

ITP’s script-writable storage cap targets storage written by JavaScript — localStorage, IndexedDB, the Cache API, and cookies set via document.cookie. A server-set HttpOnly cookie is not script-writable, so it survives the cap, which is exactly why you route session recovery through it. Cookie lifetimes are still subject to other ITP rules, so keep the refresh window sensible.

Will navigator.storage.persist() fully prevent the wipe on iOS?

Not reliably in a browser tab. On iOS the durable path in practice is installing the app to the Home Screen, which grants a more persistent storage context. Treat persist() as a helpful request that reduces wipe frequency, and still architect server-side recovery so a wipe is harmless. See Storage Partitioning & Privacy Controls for the broader durability picture.

How can I tell ITP cleared my storage versus a normal logout?

Look for the signature: data that was definitely present is wholly gone (not corrupt), only Safari and iOS users are affected, and the loss correlates with a multi-day gap in usage rather than a deliberate logout or an app update. Cold-start telemetry that fires when expected storage is empty for a previously active user confirms it at scale.

Related