← Back to DevBytes

Implementing Web Storage Quotas in Modern Web Applications

Understanding Web Storage Quotas

Web storage quotas define the maximum amount of data a web application can store in the browser using mechanisms like localStorage, sessionStorage, IndexedDB, and Cache Storage. Unlike the seemingly unlimited storage of native applications, web browsers impose strict limits to prevent abuse, protect user privacy, and ensure fair resource allocation across all open tabs and origins.

Historically, the localStorage API provided a simple key-value store capped at approximately 5MB per origin across most browsers. Modern storage APIs like IndexedDB and the newer Storage Foundation API offer significantly larger quotas—often gigabytes—but these limits are dynamic, browser-dependent, and can change based on available disk space and user behavior.

The Storage Landscape: Limits by API

Different storage mechanisms have different default limits and behaviors:

Why Storage Quotas Matter

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Ignoring quota management leads to application failures that are difficult to debug. When storage fills up, write operations silently fail or throw exceptions that often go unhandled. For progressive web applications (PWAs) that rely on offline functionality, hitting the quota limit means service worker caches cannot update, IndexedDB transactions abort, and the application degrades without warning.

Beyond failure handling, quotas matter for:

Querying Storage Quota and Usage

The modern approach to quota inspection relies on the StorageManager interface, accessible via navigator.storage. This API provides two essential methods: estimate() and persist().

The StorageManager.estimate() Method

Calling estimate() returns a promise that resolves to an object with two properties: usage (bytes currently consumed by the origin) and quota (bytes available to the origin). This accounts for all storage mechanisms combined—localStorage, IndexedDB, Cache Storage, and OPFS.

// Basic quota estimation
async function checkStorageQuota() {
  if (navigator.storage && navigator.storage.estimate) {
    const estimate = await navigator.storage.estimate();
    const usageMB = (estimate.usage / (1024 * 1024)).toFixed(2);
    const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(2);
    const percentUsed = ((estimate.usage / estimate.quota) * 100).toFixed(1);
    
    console.log(`Storage used: ${usageMB} MB`);
    console.log(`Storage quota: ${quotaMB} MB`);
    console.log(`Percentage used: ${percentUsed}%`);
    
    return { usage: estimate.usage, quota: estimate.quota, percentUsed };
  }
  // Fallback for older browsers
  console.warn('StorageManager API not available');
  return null;
}

// Invoke and handle the result
checkStorageQuota().then(result => {
  if (result && result.percentUsed > 80) {
    console.warn('Storage usage is high. Consider cleanup.');
  }
});

The returned quota value represents the total bytes available across all storage types for the current origin. Notably, this number can fluctuate—browsers may shrink quotas under disk pressure, and the estimate is a snapshot, not a reservation.

Detailed Breakdown by Storage Type

The estimate() method gives an aggregate view. To get granular usage data per storage mechanism, you need to query each API individually. Here's how to combine multiple measurements into a comprehensive dashboard:

async function getDetailedStorageReport() {
  const report = {
    localStorage: 0,
    sessionStorage: 0,
    indexedDB: 0,
    cacheStorage: 0,
    totalEstimate: null
  };
  
  // Measure localStorage (approximate via key iteration)
  let lsBytes = 0;
  for (let i = 0; i < localStorage.length; i++) {
    const key = localStorage.key(i);
    const value = localStorage.getItem(key);
    if (key && value) {
      lsBytes += key.length + value.length;
    }
  }
  report.localStorage = lsBytes;
  
  // Measure sessionStorage similarly
  let ssBytes = 0;
  for (let i = 0; i < sessionStorage.length; i++) {
    const key = sessionStorage.key(i);
    const value = sessionStorage.getItem(key);
    if (key && value) {
      ssBytes += key.length + value.length;
    }
  }
  report.sessionStorage = ssBytes;
  
  // Measure IndexedDB usage
  if (navigator.storage && navigator.storage.estimate) {
    report.totalEstimate = await navigator.storage.estimate();
  }
  
  // Measure Cache Storage usage
  const cacheNames = await caches.keys();
  let cacheTotalSize = 0;
  for (const cacheName of cacheNames) {
    const cache = await caches.open(cacheName);
    const requests = await cache.keys();
    for (const request of requests) {
      const response = await cache.match(request);
      if (response) {
        const clonedResponse = response.clone();
        const blob = await clonedResponse.blob();
        cacheTotalSize += blob.size;
      }
    }
  }
  report.cacheStorage = cacheTotalSize;
  
  console.table({
    'localStorage (approx)': `${(report.localStorage / 1024).toFixed(2)} KB`,
    'sessionStorage (approx)': `${(report.sessionStorage / 1024).toFixed(2)} KB`,
    'Cache Storage': `${(report.cacheStorage / (1024 * 1024)).toFixed(2)} MB`,
    'Total Usage (estimate)': `${(report.totalEstimate.usage / (1024 * 1024)).toFixed(2)} MB`,
    'Total Quota': `${(report.totalEstimate.quota / (1024 * 1024)).toFixed(2)} MB`
  });
  
  return report;
}

Handling Storage Pressure Events

Modern browsers emit storage pressure events when the system approaches capacity. Listening to these events allows your application to proactively reduce its footprint before the browser forcibly evicts data. The primary event is the storage-pressure event on the navigator.storage object.

// Register a storage pressure listener
if (navigator.storage) {
  navigator.storage.addEventListener('storage-pressure', async (event) => {
    console.warn('Storage pressure detected! Initiating emergency cleanup.');
    
    // Attempt to free space by deleting old cached responses
    const cacheNames = await caches.keys();
    for (const name of cacheNames) {
      // Keep only the most recent cache version
      if (name.startsWith('app-static-v')) {
        const versions = cacheNames
          .filter(c => c.startsWith('app-static-v'))
          .sort();
        if (versions.length > 1 && name !== versions[versions.length - 1]) {
          await caches.delete(name);
          console.log(`Deleted old cache: ${name}`);
        }
      }
    }
    
    // Trim IndexedDB data
    await trimOldRecords();
  });
}

async function trimOldRecords() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open('appDatabase', 1);
    request.onsuccess = (event) => {
      const db = event.target.result;
      const transaction = db.transaction('logs', 'readwrite');
      const store = transaction.objectStore('logs');
      // Delete records older than 7 days
      const sevenDaysAgo = Date.now() - (7 * 24 * 60 * 60 * 1000);
      const range = IDBKeyRange.upperBound(sevenDaysAgo);
      const deleteRequest = store.delete(range);
      deleteRequest.onsuccess = () => resolve();
      deleteRequest.onerror = () => reject(deleteRequest.error);
    };
    request.onerror = () => reject(request.error);
  });
}

Note that storage-pressure event support varies across browsers. Chrome has implemented it behind a flag; always check for API availability and fall back to periodic quota checks in production code.

Requesting Persistent Storage

By default, browsers treat all origin storage as "best-effort" or "temporary," meaning the browser may evict data without user confirmation when disk space runs low. For applications that genuinely need durable storage—like a note-taking PWA or a photo editor storing user projects—you can request persistent storage via navigator.storage.persist().

async function requestPersistentStorage() {
  if (!navigator.storage || !navigator.storage.persist) {
    console.warn('Persistent storage API not supported in this browser.');
    return false;
  }
  
  // Check if persistence is already granted
  const isPersisted = await navigator.storage.persisted();
  if (isPersisted) {
    console.log('Storage is already persistent. Data is protected from eviction.');
    return true;
  }
  
  // Request persistence
  const granted = await navigator.storage.persist();
  if (granted) {
    console.log('Persistent storage granted! Your data will not be auto-evicted.');
  } else {
    console.warn('Persistent storage denied. Data may be evicted under disk pressure.');
    // Implement a fallback: sync data to a server, warn the user, etc.
  }
  
  return granted;
}

// Call this early in the application lifecycle
document.addEventListener('DOMContentLoaded', () => {
  requestPersistentStorage().then(granted => {
    if (!granted) {
      // Show a one-time notice to the user about storage reliability
      showStorageWarningBanner();
    }
  });
});

The persistence grant is not a technical guarantee of unlimited storage. Even with persistence, the quota still applies, and the browser may still evict data in extreme circumstances (e.g., OS-level disk corruption recovery). However, persistent storage prevents the browser's routine cleanup from targeting your origin, giving you significantly more reliability.

Browser Behavior for Persistence Requests

Browsers handle the persist() call differently based on heuristics of user engagement:

Implementing a Proactive Quota Management Strategy

Rather than reactively handling failures when writes throw exceptions, implement a quota-aware write pipeline that checks available space before committing large operations. Below is a practical wrapper for IndexedDB writes that verifies capacity and gracefully degrades when limits are near.

class QuotaAwareStorage {
  constructor(options = {}) {
    this.warningThreshold = options.warningThreshold || 0.75; // 75% usage triggers warning
    this.criticalThreshold = options.criticalThreshold || 0.90; // 90% triggers write refusal
    this.dbName = options.dbName || 'AppStorage';
    this.db = null;
  }
  
  async init() {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, 1);
      request.onupgradeneeded = (event) => {
        const db = event.target.result;
        if (!db.objectStoreNames.contains('data')) {
          db.createObjectStore('data', { keyPath: 'id', autoIncrement: true });
        }
      };
      request.onsuccess = (event) => {
        this.db = event.target.result;
        resolve();
      };
      request.onerror = () => reject(request.error);
    });
  }
  
  async safeWrite(data) {
    // Estimate current usage before attempting write
    const estimate = await navigator.storage.estimate();
    const usageRatio = estimate.usage / estimate.quota;
    const dataSize = new TextEncoder().encode(JSON.stringify(data)).length;
    const projectedUsage = estimate.usage + dataSize;
    
    if (usageRatio > this.criticalThreshold) {
      throw new Error(
        `Storage critical: ${(usageRatio * 100).toFixed(1)}% used. ` +
        `Write operation blocked to prevent data loss.`
      );
    }
    
    if (projectedUsage > estimate.quota * 0.95) {
      console.warn(
        `Write would consume ${((projectedUsage / estimate.quota) * 100).toFixed(1)}% ` +
        `of quota. Consider reducing payload size.`
      );
    }
    
    // Proceed with the write
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction('data', 'readwrite');
      const store = transaction.objectStore('data');
      const addRequest = store.add({ payload: data, timestamp: Date.now() });
      addRequest.onsuccess = () => resolve(addRequest.result);
      addRequest.onerror = () => {
        // Check if the error is quota-related
        if (addRequest.error.name === 'QuotaExceededError') {
          reject(new Error('Storage quota exceeded during write.'));
        } else {
          reject(addRequest.error);
        }
      };
    });
  }
  
  async writeWithFallback(data) {
    try {
      return await this.safeWrite(data);
    } catch (error) {
      if (error.message.includes('Storage critical') || 
          error.message.includes('quota exceeded')) {
        // Fallback: trim older entries to make room
        console.warn('Attempting emergency cleanup before retrying write.');
        await this.trimOldestEntries(10);
        // Retry once after cleanup
        try {
          return await this.safeWrite(data);
        } catch (retryError) {
          // Ultimate fallback: store minimal version or sync to server
          throw new Error(
            'Unable to persist data even after cleanup. ' +
            'Please free up storage space.'
          );
        }
      }
      throw error;
    }
  }
  
  async trimOldestEntries(count) {
    return new Promise((resolve, reject) => {
      const transaction = this.db.transaction('data', 'readwrite');
      const store = transaction.objectStore('data');
      const index = store.index('timestamp');
      // Assuming an index on timestamp exists; simplified here
      const cursorRequest = store.openCursor(null, 'prev');
      let removed = 0;
      cursorRequest.onsuccess = (event) => {
        const cursor = event.target.result;
        if (cursor && removed < count) {
          cursor.delete();
          removed++;
          cursor.continue();
        } else {
          resolve(removed);
        }
      };
      cursorRequest.onerror = () => reject(cursorRequest.error);
    });
  }
}

// Usage example
async function demoQuotaAwareStorage() {
  const storage = new QuotaAwareStorage({ dbName: 'MyAppData' });
  await storage.init();
  
  const largePayload = {
    entries: Array.from({ length: 5000 }, (_, i) => ({
      id: i,
      description: 'Sample data entry for quota testing',
      metadata: { created: Date.now(), version: '1.0' }
    }))
  };
  
  try {
    const id = await storage.writeWithFallback(largePayload);
    console.log(`Data successfully stored with ID: ${id}`);
  } catch (error) {
    console.error('Storage operation failed:', error.message);
    // Notify the user and offer manual cleanup options
  }
}

Cross-Browser Quota Considerations

Quota behavior diverges significantly across browsers, and applications must account for these differences:

// Detect browser-specific quota characteristics
async function analyzeQuotaEnvironment() {
  const estimate = await navigator.storage.estimate();
  const userAgent = navigator.userAgent;
  
  let environment = {
    browser: 'unknown',
    quotaLimit: estimate.quota,
    isPersistent: false,
    notes: []
  };
  
  if (userAgent.includes('Chrome') && !userAgent.includes('Edg')) {
    environment.browser = 'Chrome';
    // Chrome allows up to 80% of total disk space per origin for persistent storage
    environment.notes.push(
      'Chrome typically provides generous quotas, especially for installed PWAs.'
    );
  } else if (userAgent.includes('Firefox')) {
    environment.browser = 'Firefox';
    // Firefox caps at 50% of disk space for the entire group
    environment.notes.push(
      'Firefox enforces a global storage cap across all origins.'
    );
  } else if (userAgent.includes('Safari') && !userAgent.includes('Chrome')) {
    environment.browser = 'Safari';
    // Safari on iOS limits to ~1GB and evicts after 7 days of inactivity
    environment.notes.push(
      'Safari applies aggressive eviction policies. Implement regular data sync.'
    );
    environment.notes.push(
      'Safari on iOS may cap storage at 1GB per origin regardless of device capacity.'
    );
  }
  
  if (navigator.storage && navigator.storage.persisted) {
    environment.isPersistent = await navigator.storage.persisted();
  }
  
  console.log('Quota Environment:', environment);
  return environment;
}

For Safari specifically, the 7-day eviction rule after the last user interaction means that PWAs intended for offline use must implement periodic background syncs or encourage users to interact with the app regularly. The persist() API is not supported in Safari, so alternative strategies—like displaying an in-app reminder to launch the app at least weekly—become necessary.

Best Practices for Storage Quota Management

1. Monitor Proactively, Not Reactively

Implement a monitoring layer that periodically checks quota usage and logs warnings at configurable thresholds. Don't wait for write failures—by then, the user experience has already degraded.

// Periodic quota monitoring service
class QuotaMonitor {
  constructor(intervalMs = 60000) {
    this.intervalMs = intervalMs;
    this.listeners = [];
    this.running = false;
  }
  
  addListener(callback) {
    this.listeners.push(callback);
  }
  
  start() {
    this.running = true;
    this._check();
    this.timer = setInterval(() => this._check(), this.intervalMs);
  }
  
  stop() {
    this.running = false;
    clearInterval(this.timer);
  }
  
  async _check() {
    const estimate = await navigator.storage.estimate();
    const ratio = estimate.usage / estimate.quota;
    const status = {
      ratio,
      usageMB: estimate.usage / (1024 * 1024),
      quotaMB: estimate.quota / (1024 * 1024),
      level: ratio > 0.9 ? 'critical' : ratio > 0.7 ? 'warning' : 'normal'
    };
    
    for (const listener of this.listeners) {
      listener(status);
    }
  }
}

const monitor = new QuotaMonitor(30000); // Check every 30 seconds
monitor.addListener(status => {
  if (status.level === 'critical') {
    console.error(`CRITICAL: Storage at ${(status.ratio * 100).toFixed(1)}%`);
    // Trigger aggressive cleanup, pause cache writes, alert user
  } else if (status.level === 'warning') {
    console.warn(`WARNING: Storage at ${(status.ratio * 100).toFixed(1)}%`);
    // Start gentle cleanup, reduce logging verbosity
  }
});
monitor.start();

2. Implement Graceful Degradation

When storage approaches its limit, your application should degrade functionality gracefully rather than crashing. For a photo editor, this might mean reducing undo history depth. For a chat application, it could mean caching only recent conversations offline.

// Adaptive storage policy based on available quota
async function determineCacheStrategy() {
  const estimate = await navigator.storage.estimate();
  const availableMB = (estimate.quota - estimate.usage) / (1024 * 1024);
  
  if (availableMB > 500) {
    return {
      maxOfflinePages: 50,
      imageQuality: 'high',
      syncInterval: 'hourly',
      retentionDays: 30
    };
  } else if (availableMB > 100) {
    return {
      maxOfflinePages: 20,
      imageQuality: 'medium',
      syncInterval: 'daily',
      retentionDays: 14
    };
  } else {
    return {
      maxOfflinePages: 5,
      imageQuality: 'low',
      syncInterval: 'weekly',
      retentionDays: 3
    };
  }
}

3. Use IndexedDB for Large Data, Not localStorage

localStorage is synchronous and blocks the main thread. Its 5–10MB cap makes it unsuitable for anything beyond trivial configuration data. IndexedDB provides asynchronous access, transaction support, and far larger effective quotas. Migrate early.

4. Clean Up Stale Data on a Schedule

Implement a service worker periodic cleanup or a page-load cleanup routine that prunes expired entries. This prevents slow accumulation that eventually triggers quota exhaustion.

5. Test Under Realistic Constraints

Use browser DevTools to simulate storage limits. In Chrome, you can override storage quota in the Application panel under "Storage." Test with artificially small quotas (e.g., 10MB) to ensure your application handles limits correctly before users encounter them.

6. Communicate Storage Status to Users

Provide a settings panel or status indicator showing storage consumption. Users appreciate transparency, and it gives them agency to clear data manually if needed.

// Build a simple storage status widget
async function renderStorageStatus(containerId) {
  const container = document.getElementById(containerId);
  if (!container) return;
  
  const estimate = await navigator.storage.estimate();
  const usageMB = (estimate.usage / (1024 * 1024)).toFixed(1);
  const quotaMB = (estimate.quota / (1024 * 1024)).toFixed(1);
  const percent = ((estimate.usage / estimate.quota) * 100).toFixed(0);
  
  const isPersisted = navigator.storage.persisted 
    ? await navigator.storage.persisted() 
    : false;
  
  container.innerHTML = `
    

${usageMB} MB used of ${quotaMB} MB (${percent}%) ${isPersisted ? '🔒 Persistent storage enabled' : '⚠️ Storage may be evicted'}

`; }

Working with the Storage Buckets API (Experimental)

The Storage Buckets API is an emerging standard that allows creating named, independently managed storage containers within an origin. Each bucket has its own quota, persistence state, and eviction priority. This is useful for separating critical data (user documents) from ephemeral data (analytics logs) so that eviction can target the right bucket.

// Using Storage Buckets API (available in Chrome 122+)
async function createStorageBucket() {
  if (!navigator.storageBuckets) {
    console.warn('Storage Buckets API not available.');
    return null;
  }
  
  try {
    // Create or open a named bucket for critical user data
    const criticalBucket = await navigator.storageBuckets.open('critical-user-data', {
      persisted: true,
      quota: 100 * 1024 * 1024 // Request 100MB for this bucket
    });
    
    // Use the bucket's IndexedDB interface
    const db = await new Promise((resolve, reject) => {
      const request = criticalBucket.indexedDB.open('UserDocs', 1);
      request.onsuccess = () => resolve(request.result);
      request.onerror = () => reject(request.error);
    });
    
    console.log('Critical storage bucket ready:', criticalBucket.name);
    return criticalBucket;
  } catch (error) {
    console.error('Failed to create storage bucket:', error);
    return null;
  }
}

// Create a separate bucket for non-critical analytics data
async function createEphemeralBucket() {
  const ephemeralBucket = await navigator.storageBuckets.open('analytics-logs', {
    persisted: false, // This data can be evicted freely
    quota: 10 * 1024 * 1024 // 10MB cap
  });
  return ephemeralBucket;
}

Because the Storage Buckets API is still evolving, always feature-detect before use and maintain fallback code paths using traditional quota management techniques.

Debugging Quota Issues in Development

Reproducing quota errors during development requires deliberate constraint. Here's a utility that simulates storage filling for testing purposes:

// Test utility: Fill storage to a target percentage for debugging
async function fillStorageTo(targetPercent = 85) {
  const estimate = await navigator.storage.estimate();
  const targetBytes = Math.floor(estimate.quota * (targetPercent / 100));
  const currentBytes = estimate.usage;
  const bytesToFill = targetBytes - currentBytes;
  
  if (bytesToFill <= 0) {
    console.log('Storage already at or above target percentage.');
    return;
  }
  
  console.log(`Filling storage with ${(bytesToFill / (1024 * 1024)).toFixed(2)} MB of test data...`);
  
  // Open a dedicated test IndexedDB
  const db = await new Promise((resolve, reject) => {
    const request = indexedDB.open('QuotaTestDB', 1);
    request.onupgradeneeded = (e) => {
      e.target.result.createObjectStore('blobs', { keyPath: 'id' });
    };
    request.onsuccess = () => resolve(request.result);
    request.onerror = () => reject(request.error);
  });
  
  const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
  let bytesWritten = 0;
  let chunkId = 0;
  
  while (bytesWritten < bytesToFill) {
    const remainingBytes = bytesToFill - bytesWritten;
    const currentChunkSize = Math.min(CHUNK_SIZE, remainingBytes);
    const blob = new Blob([new ArrayBuffer(currentChunkSize)], { type: 'application/octet-stream' });
    
    try {
      await new Promise((resolve, reject) => {
        const tx = db.transaction('blobs', 'readwrite');
        const store = tx.objectStore('blobs');
        store.add({ id: chunkId++, blob });
        tx.oncomplete = () => resolve();
        tx.onerror = () => reject(tx.error);
      });
      bytesWritten += currentChunkSize;
      console.log(`Written ${((bytesWritten / (1024 * 1024)).toFixed(2))} MB so far...`);
    } catch (error) {
      console.error('Storage fill aborted due to error:', error.message);
      break;
    }
  }
  
  console.log('Storage fill complete. Test your application behavior at this level.');
}

// Clean up test data
async function clearTestStorage() {
  const deleteRequest = indexedDB.deleteDatabase('QuotaTestDB');
  deleteRequest.onsuccess = () => console.log('Test storage database deleted.');
  deleteRequest.onerror = () => console.error('Failed to delete test database.');
}

Conclusion

Web storage quotas are an intrinsic constraint of the browser platform, not a bug to be worked around. By understanding the StorageManager API, requesting persistent storage where appropriate, monitoring usage proactively, and implementing graceful degradation, you can build web applications that are resilient across browsers, devices, and storage conditions. The key takeaway is to treat quota management as a first-class architectural concern—just as you plan for network failures, plan for storage exhaustion. With the techniques covered in this tutorial, your application can deliver reliable offline experiences, protect user data from eviction, and adapt intelligently to the storage realities of each user's environment.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles