How Much Storage Does Your Browser Really Give You?
Problem
I was building an offline-first web app that caches user data locally. The app worked fine during testing, but in production, users started reporting data loss. One user lost months of cached entries overnight.
When I investigated, I found this in the console:
Uncaught DOMException: Failed to execute 'setItem' on 'Storage': Setting the value exceeded the quota.I had assumed browser storage was “basically unlimited” for IndexedDB. That assumption cost my users their data.
What I Found
I searched for answers and found conflicting information:
- “IndexedDB and OPFS are practically unlimited”
- “localStorage is only 5MB”
- “You have no control over available space”
- “Storage is best-effort ephemeral cache”
A Reddit discussion put it bluntly:
“You have no control over the available space and cannot rely on storing the world there.”
“QuotaExceededError causes production outages.”
MDN documentation confirmed the hard limits:
“Browsers can store up to 5 MiB of local storage per origin. Once this limit is reached, browsers throw a
QuotaExceededErrorexception.”
For IndexedDB and OPFS, the situation is different but not simpler:
“Subject to browser storage quota restrictions” with quotas determined per-browser.
Storage Limits by API
I created a table to understand the differences:
| Storage API | Limit | Behavior on Limit | Persistence ||-------------------|--------------------|--------------------------|------------------|| localStorage | 5MB per origin | QuotaExceededError | Never persistent || sessionStorage | 5MB per origin | QuotaExceededError | Session-only || IndexedDB | Browser-dependent | QuotaExceededError | Can request || OPFS | Same as IndexedDB | QuotaExceededError | Can request |The key insight: localStorage and sessionStorage share the 5MB limit per origin. That’s 10MB total for Web Storage. IndexedDB and OPFS share a larger quota (typically several GB), but the exact amount varies by browser and device.
How I Checked My Quota
The Storage API provides a way to check your actual quota:
async function checkStorageQuota() { if (navigator.storage && navigator.storage.estimate) { const estimate = await navigator.storage.estimate();
console.log(`Quota: ${(estimate.quota / 1024 / 1024).toFixed(2)} MB`); console.log(`Used: ${(estimate.usage / 1024 / 1024).toFixed(2)} MB`); console.log(`Available: ${((estimate.quota - estimate.usage) / 1024 / 1024).toFixed(2)} MB`);
return estimate; } else { console.log('Storage API not supported in this browser'); return null; }}
// Run in browser consolecheckStorageQuota();On my Chrome browser, I got:
Quota: 966.00 MBUsed: 12.35 MBAvailable: 953.65 MBOn my colleague’s phone:
Quota: 50.00 MBUsed: 8.00 MBAvailable: 42.00 MBThe quota varies dramatically. Desktop browsers allocate hundreds of MB. Mobile browsers might give you only 50MB. This explains why mobile users were losing data first.
Storage Eviction: The LRU Policy
When device storage runs low, browsers start deleting data. The eviction policy is Least Recently Used (LRU):
Storage Pressure Detected ↓Identify non-persistent origins ↓Sort by last access time (oldest first) ↓Delete all storage for LRU origin ↓Repeat until pressure relievedMDN explains:
“Browsers use a Least Recently Used (LRU) policy under storage pressure.”
This means your app’s data can disappear even if you’re under quota. If the user’s device fills up, the browser will purge origins that haven’t been accessed recently.
Critical point: Persistent storage is skipped during automatic eviction. This is the protection mechanism.
Requesting Persistent Storage
The Storage API allows requesting persistent storage:
async function requestPersistentStorage() { if (!navigator.storage || !navigator.storage.persist) { console.log('Storage API not supported'); return false; }
// Check if already persistent const isPersistent = await navigator.storage.persisted(); console.log(`Currently persistent: ${isPersistent}`);
if (isPersistent) { return true; }
// Request persistent storage const granted = await navigator.storage.persist(); console.log(`Persistent storage granted: ${granted}`);
return granted;}
// Request persistence for critical datarequestPersistentStorage();But there’s a catch. The browser decides whether to grant persistence, not your app. Criteria vary by browser:
Chrome: Grants based on site engagement score (how often user visits) Firefox: Always grants if site is bookmarked Safari: Generally grants for installed apps (PWA)
I tested this across browsers:
async function testPersistenceSupport() { const results = { storageApi: !!navigator.storage, estimate: !!navigator.storage?.estimate, persist: !!navigator.storage?.persist, persisted: await navigator.storage?.persisted?.() ?? false };
console.table(results); return results;}
testPersistenceSupport();Results on Chrome desktop:
| Property | Value ||-----------------|--------|| storageApi | true || estimate | true || persist | true || persisted | false |After calling persist():
| Property | Value ||-----------------|--------|| persisted | true |On Safari mobile, it was granted automatically for my PWA. On Firefox, I had to bookmark the site first.
Practical Storage Strategy
Based on this research, I changed my storage strategy:
1. Check Before Write
Never write blindly. Check available space first:
async function safeWrite(key, data) { try { // For small data, use localStorage with check const dataSize = new Blob([JSON.stringify(data)]).size;
if (dataSize < 1024 * 1024) { // Less than 1MB // Check localStorage remaining space (rough estimate) const current = JSON.stringify(localStorage).length; const remaining = 5 * 1024 * 1024 - current; // 5MB limit
if (dataSize > remaining) { console.warn('localStorage approaching limit, using IndexedDB'); return await writeToIndexedDB(key, data); }
localStorage.setItem(key, JSON.stringify(data)); return true; }
// Large data goes to IndexedDB return await writeToIndexedDB(key, data);
} catch (e) { if (e.name === 'QuotaExceededError') { console.error('Storage quota exceeded'); // Fallback: notify user or sync to server return false; } throw e; }}2. Monitor Storage Usage
Track usage over time:
class StorageMonitor { constructor() { this.history = []; }
async check() { if (!navigator.storage?.estimate) { return null; }
const estimate = await navigator.storage.estimate(); const percentUsed = (estimate.usage / estimate.quota) * 100;
this.history.push({ timestamp: Date.now(), usage: estimate.usage, quota: estimate.quota, percentUsed: percentUsed });
// Warn at 80% if (percentUsed > 80) { console.warn(`Storage usage at ${percentUsed.toFixed(1)}%. Consider cleanup.`); }
return estimate; }
getHistory() { return this.history; }}
// Use in appconst monitor = new StorageMonitor();setInterval(() => monitor.check(), 60000); // Check every minute3. Implement Fallback
When storage fails, have a backup plan:
async function saveWithFallback(data) { // Try local storage first try { await writeToIndexedDB('userData', data); return { location: 'local', success: true }; } catch (e) { if (e.name === 'QuotaExceededError') { // Storage full - try server sync console.warn('Local storage full, syncing to server');
try { await syncToServer(data); return { location: 'server', success: true }; } catch (serverError) { // Both failed console.error('All storage options failed'); return { location: 'none', success: false }; } } throw e; }}4. Request Persistence for Critical Data
async function initCriticalStorage() { // Request persistent storage for user data const persistent = await requestPersistentStorage();
if (!persistent) { // Warn user their data may be cleared showWarning( 'Your browser may clear cached data under storage pressure. ' + 'Important data is synced to server.' ); }
// Store critical data with persistence awareness return persistent;}Testing Storage Limits
I wrote a script to find actual limits:
async function findLocalStorageLimit() { const testKey = '_limit_test'; const chunkSize = 1024; // 1KB chunks let totalSize = 0; let chunks = '';
try { while (true) { chunks += 'x'.repeat(chunkSize); localStorage.setItem(testKey, chunks); totalSize += chunkSize;
// Stop at reasonable upper bound if (totalSize > 10 * 1024 * 1024) break; } } catch (e) { if (e.name === 'QuotaExceededError') { console.log(`localStorage limit: ${totalSize / 1024 / 1024} MB`); localStorage.removeItem(testKey); return totalSize; } throw e; }
localStorage.removeItem(testKey); return totalSize;}
// Result: ~5MB on most browsersfindLocalStorageLimit();For IndexedDB:
async function findIndexedDBLimit() { // Open database const db = await new Promise((resolve, reject) => { const request = indexedDB.open('LimitTest', 1); request.onupgradeneeded = () => { request.result.createObjectStore('test'); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const chunkSize = 1024 * 1024; // 1MB chunks let totalSize = 0;
try { while (true) { const chunk = new ArrayBuffer(chunkSize);
await new Promise((resolve, reject) => { const tx = db.transaction('test', 'readwrite'); const store = tx.objectStore('test'); store.put(chunk, `chunk_${totalSize}`); tx.oncomplete = resolve; tx.onerror = reject; });
totalSize += chunkSize;
// Check quota const estimate = await navigator.storage.estimate(); if (estimate.usage >= estimate.quota * 0.95) { console.log(`IndexedDB filled quota: ${totalSize / 1024 / 1024} MB`); break; }
// Stop at 1GB for safety if (totalSize >= 1024 * 1024 * 1024) break; } } catch (e) { console.log(`IndexedDB limit reached at: ${totalSize / 1024 / 1024} MB`); console.log(`Error: ${e.name}`); }
// Cleanup indexedDB.deleteDatabase('LimitTest'); return totalSize;}
findIndexedDBLimit();Results vary by browser and device:
| Browser/Device | localStorage | IndexedDB ||------------------|--------------|--------------|| Chrome Desktop | 5.0 MB | ~1GB || Chrome Mobile | 5.0 MB | ~50-200 MB || Safari Desktop | 5.0 MB | ~500MB || Safari Mobile | 5.0 MB | ~50MB || Firefox Desktop | 5.0 MB | ~2GB |Common Mistakes I Made
1. Assuming “Unlimited” Storage
I believed IndexedDB was “basically unlimited.” Reality: quotas vary from 50MB on mobile to 2GB on desktop.
2. Not Handling QuotaExceededError
My code crashed when storage filled. Now every storage operation has error handling:
try { localStorage.setItem(key, value);} catch (e) { if (e.name === 'QuotaExceededError') { // Handle gracefully handleStorageFull(); }}3. Not Requesting Persistence
Critical user data was stored without persistence request. The browser could delete it anytime.
4. Storing Too Much in localStorage
I cached images in localStorage. Images are large. localStorage is small. This was obvious once I knew the limits.
5. Assuming Desktop = Mobile
My app worked on desktop (1GB quota) but failed on mobile (50MB quota). Test on mobile devices.
Summary
Browser storage is a cache, not a database. Key takeaways:
- localStorage is 5MB - Hard limit, no negotiation
- IndexedDB/OPFS have variable quotas - 50MB on mobile, GB on desktop
- Storage can be evicted - LRU policy under storage pressure
- Persistence is opt-in - Request with
navigator.storage.persist() - Check before write - Use
navigator.storage.estimate() - Handle errors - Wrap storage in try-catch for QuotaExceededError
- Design for loss - Assume data can disappear; sync critical data to server
The golden rule: Never rely solely on browser storage for critical user data. Use it for performance optimization and offline capability, but maintain server-side backups.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments