Skip to content

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 QuotaExceededError exception.”

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:

check-quota.js
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 console
checkStorageQuota();

On my Chrome browser, I got:

Quota: 966.00 MB
Used: 12.35 MB
Available: 953.65 MB

On my colleague’s phone:

Quota: 50.00 MB
Used: 8.00 MB
Available: 42.00 MB

The 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 relieved

MDN 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:

request-persistent.js
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 data
requestPersistentStorage();

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:

test-persistence.js
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:

safe-write.js
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:

monitor-storage.js
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 app
const monitor = new StorageMonitor();
setInterval(() => monitor.check(), 60000); // Check every minute

3. Implement Fallback

When storage fails, have a backup plan:

storage-fallback.js
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

critical-persistence.js
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:

test-limits.js
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 browsers
findLocalStorageLimit();

For IndexedDB:

test-indexeddb-limit.js
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:

error-handling.js
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:

  1. localStorage is 5MB - Hard limit, no negotiation
  2. IndexedDB/OPFS have variable quotas - 50MB on mobile, GB on desktop
  3. Storage can be evicted - LRU policy under storage pressure
  4. Persistence is opt-in - Request with navigator.storage.persist()
  5. Check before write - Use navigator.storage.estimate()
  6. Handle errors - Wrap storage in try-catch for QuotaExceededError
  7. 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