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.
Primary Symptoms:
- IndexedDB databases vanish after a full browser restart
- Service Worker caches return empty responses during offline routing
- No explicit error is thrown during the background eviction event
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:
- Missing
navigator.storage.persist()invocation during initialization - Lack of proactive quota monitoring via
navigator.storage.estimate() - Cross-browser differences in eviction thresholds (Chromium vs. WebKit vs. Gecko)
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:
- [ ] Confirm
navigator.storage.persisted()resolves totruein the DevTools Console - [ ] Simulate low-disk conditions via Chrome DevTools > Application > Storage > Clear site data (partial)
- [ ] Verify IndexedDB databases survive a full browser restart
- [ ] Monitor
QuotaExceededErrorfrequency in your production error tracking
Debugging Tools:
- Chromium:
chrome://storage-internals/— view origin persistence flags and quota allocation - Firefox:
about:debugging> This Firefox > Service Workers — inspect Service Worker cache state and storage limits - Console Diagnostics:
navigator.storage.estimate().then(console.log)— returns{usage, quota}in bytes - Console Diagnostics:
navigator.storage.persisted().then(console.log)— returnstrueif the origin has been granted persistent storage
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
- Storage Quotas & Eviction Policies — the parent guide on allocation tiers and persistence.
- Browser Storage Limits Across Chrome, Firefox, and Safari — per-engine thresholds behind these evictions.
- The iOS Safari 7-Day Storage Eviction Workaround — surviving WebKit’s inactivity timer.
- Storage Partitioning & Privacy Controls — the privacy engines that clear storage outside the quota model.
- Browser Storage Fundamentals & Quotas — the foundational storage primitives.