Debugging storage eviction in progressive web apps

Offline-first progressive web apps frequently experience silent data loss following device reboots or OS-level memory pressure. Developers encounter QuotaExceededError during write operations or discover previously cached assets missing without explicit delete() calls. This behavior stems directly from default browser storage classifications described in the parent guide on Storage Quotas & Eviction Policies, itself part of Browser Storage Fundamentals & Quotas.

Eviction recovery sequence A sequence showing how a PWA detects evicted storage at startup and recovers by re-requesting persistence and re-syncing from the network. App startup open IndexedDB Integrity probe stores present? Stores missing evicted Re-persist persist() again Re-sync network re-hydrate Recovered cold start done

Primary Symptoms:

Root Cause: Best-Effort Storage Classification

Modern browsers classify origin storage as best-effort by default. When system disk space falls below critical thresholds, the operating system triggers the eviction routines described in Storage Quotas & Eviction Policies to reclaim space. Non-persistent origins are prioritized for eviction, and because the deletion happens out-of-band — not during any API call your code made — it bypasses standard window.onerror or Promise.catch handlers entirely. The Storage Standard frames this explicitly: a best-effort bucket may be cleared by the user agent at any time without notification, whereas a persisted bucket is only cleared on explicit user action.

Technical Factors Driving Eviction:

On WebKit specifically, eviction is compounded by an inactivity timer rather than pure disk pressure, which is why iPhone PWAs lose data on a schedule. That mechanism and its mitigations are covered separately in The iOS Safari 7-Day Storage Eviction Workaround, and the privacy engine driving it is detailed under Storage Partitioning & Privacy Controls. For the precise per-engine thresholds, see Browser Storage Limits Across Chrome, Firefox, and Safari.

Step-by-Step Fix: Enforce Persistent Storage & Handle Eviction

Implement the following workflow to secure offline state and gracefully handle storage pressure.

1. Request Persistent Storage Permission

Invoke navigator.storage.persist() during app initialization. Browsers may prompt the user or auto-grant based on engagement metrics (PWA installed, frequent visits). Handle the returned boolean to provide a degraded-mode fallback when persistence is denied.

2. Monitor Quota Usage Proactively

Poll navigator.storage.estimate() periodically. Trigger cache pruning when usage exceeds 80% of the allocated quota to prevent hard QuotaExceededError failures during critical writes.

3. Implement Eviction Fallback & Integrity Checks

Wrap IndexedDB transactions in explicit try/catch blocks. Add periodic health checks to detect missing databases and trigger network re-sync.

Production-Ready Implementation

/**
 * Secure offline storage initialization with persistence, quota monitoring,
 * and eviction-safe fallbacks.
 */
async function secureOfflineStorage() {
  try {
    // Step 1: Request persistent storage
    const isPersisted = await navigator.storage.persist();
    if (!isPersisted) {
      console.warn('Storage not persisted; data may be evicted under OS memory pressure.');
    }

    // Step 2: Estimate quota & trigger proactive pruning
    const { usage = 0, quota = 1 } = await navigator.storage.estimate();
    const usagePercent = (usage / quota) * 100;

    if (usagePercent > 80) {
      await cleanupOldCaches();
    }

    // Step 3: Verify IndexedDB integrity
    await verifyDatabaseIntegrity();

    return { persisted: isPersisted, usagePercent, quota, usage };
  } catch (err) {
    if (err.name === 'QuotaExceededError') {
      console.error('Storage quota exceeded during initialization. Triggering emergency cleanup.');
      await emergencyCacheClear();
    } else {
      console.error('Storage persistence failed:', err.name, err.message);
    }
    throw new Error('Offline storage initialization failed');
  }
}

async function cleanupOldCaches() {
  const cacheNames = await caches.keys();
  // Delete all but the most recent cache version
  const stale = cacheNames.slice(0, -1);
  await Promise.all(stale.map((name) => caches.delete(name)));
}

async function emergencyCacheClear() {
  const cacheNames = await caches.keys();
  await Promise.all(cacheNames.map((name) => caches.delete(name)));
}

async function verifyDatabaseIntegrity() {
  // Open DB; if it doesn't exist or is missing stores, trigger network sync
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('app-db');
    request.onsuccess = () => {
      const db = request.result;
      const missingStores = ['state', 'syncQueue'].filter(
        (s) => !db.objectStoreNames.contains(s),
      );
      db.close();
      if (missingStores.length > 0) {
        console.warn('Missing stores detected, triggering re-sync:', missingStores);
      }
      resolve();
    };
    request.onerror = () => reject(request.error);
  });
}

Validation: Verify Persistence and Eviction Resilience

After deployment, validate storage behavior using the following checklist and browser-native diagnostics.

Validation Checklist:

Debugging Tools:

Edge Cases & Fallback Approaches

Two edge cases catch teams off guard. First, persistence can be revoked: even after a grant, clearing site data or a browser profile reset returns the origin to best-effort, so re-check persisted() on every cold start rather than once at install time. Second, the integrity probe above only opens the default database — if your app uses several databases or relies on the Cache API for Static Assets, extend the check to enumerate each one. As a universal fallback, treat any detected loss as a network re-hydration event: queue the missing keys, fetch authoritative state from the server, and coordinate the repopulation through the Web Locks API for Cross-Tab Coordination so multiple open tabs do not race to rebuild the same store.

Frequently Asked Questions

Why is no error thrown when my IndexedDB data is evicted?

Eviction happens out-of-band when the OS reclaims disk space, not during any API call your code made, so there is nothing for window.onerror or a Promise.catch to intercept. The only signal is that a subsequent open returns an empty database. Detect it with a startup integrity probe that checks for the expected object stores and re-syncs when they are missing.

How do I confirm my origin actually has persistent storage?

Call navigator.storage.persisted() — it resolves to true only when the origin holds a durability grant. In Chromium you can also open chrome://storage-internals/ to inspect the persistence flag and current quota allocation directly. Re-check on every cold start, since a profile reset or site-data clear can revoke the grant.

Does clearing the Cache API also clear IndexedDB?

Not automatically through your code, but both live in the same best-effort bucket, so a single eviction or a user “Clear site data” action removes both at once. Build recovery to verify each store independently and re-hydrate whichever is missing rather than assuming they fail together.

Related