Skip to content

How to Fix QuotaExceededError in localStorage (Prevent App Crashes)

I got a call at 2 AM. Our production app was down. Users couldn’t save their work. The error logs showed QuotaExceededError: DOM Exception 22. I had no idea localStorage could crash my app.

The Problem

I was treating localStorage like free real estate. Store user preferences? localStorage. Cache API responses? localStorage. Remember form state? localStorage. It all worked fine in development.

But in production, some users had accumulated months of data. When localStorage hit the browser’s limit (5-10 MiB depending on the browser), everything broke. The setItem() call threw an exception that propagated up and crashed the entire application.

Here’s the dangerous code I had:

storage.js
// DANGEROUS: This crashes when localStorage is full
function saveUserPreferences(prefs) {
localStorage.setItem('userPrefs', JSON.stringify(prefs));
return true;
}
function cacheSearchResults(query, results) {
localStorage.setItem(`search_${query}`, JSON.stringify(results));
}

No try-catch. No error handling. Just raw, optimistic setItem() calls. When the quota was exceeded, the exception bubbled up and broke the user’s workflow.

Why This Happens

Browser storage isn’t unlimited. According to MDN Web Docs, browsers enforce strict quotas:

  • 5 MiB for localStorage per origin
  • 5 MiB for sessionStorage per origin
  • 10 MiB total across both storage types

When you exceed these limits, browsers throw QuotaExceededError (DOM Exception 22). This isn’t a warning or a graceful degradation—it’s an exception that will crash your code if you don’t catch it.

Common causes I’ve seen:

  1. Base64 encoded images - Someone thought storing images in localStorage for “caching” was a good idea
  2. Unbounded data accumulation - Never clearing old entries, just adding more
  3. Massive JSON objects - Storing entire API responses without trimming
  4. Multiple tabs multiplying the problem - Each tab’s data adds up

The Fix

The solution is straightforward: wrap every localStorage.setItem() call in a try-catch block. Here’s how I fixed my code:

storage-safe.js
function saveUserPreferences(prefs) {
try {
localStorage.setItem('userPrefs', JSON.stringify(prefs));
return true;
} catch (error) {
if (error.name === 'QuotaExceededError' || error.code === 22) {
console.warn('localStorage quota exceeded. Clearing old data...');
// Clear and retry
clearOldData();
try {
localStorage.setItem('userPrefs', JSON.stringify(prefs));
return true;
} catch (retryError) {
console.error('Still cannot save after clearing:', retryError);
return false;
}
}
console.error('Unexpected localStorage error:', error);
return false;
}
}

But I didn’t want to write this try-catch logic every time. So I created a helper function:

storage-helpers.js
/**
* Safely store data in localStorage with error handling
* @param {string} key - Storage key
* @param {any} value - Value to store (will be JSON stringified)
* @returns {boolean} - True if saved successfully, false otherwise
*/
function safeSetItem(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (error) {
if (error.name === 'QuotaExceededError' || error.code === 22) {
console.warn(`Storage quota exceeded for key: ${key}`);
return false;
}
console.error('localStorage error:', error);
return false;
}
}
/**
* Safely retrieve data from localStorage
* @param {string} key - Storage key
* @param {any} defaultValue - Value to return if key doesn't exist
* @returns {any} - Parsed value or default
*/
function safeGetItem(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (error) {
console.error(`Error reading localStorage key ${key}:`, error);
return defaultValue;
}
}
/**
* Check if localStorage is available and working
* @returns {boolean}
*/
function isStorageAvailable() {
try {
const test = '__storage_test__';
localStorage.setItem(test, test);
localStorage.removeItem(test);
return true;
} catch (e) {
return false;
}
}

Now my code looks like this:

app.js
// Much cleaner and safer
if (safeSetItem('userPrefs', preferences)) {
console.log('Preferences saved');
} else {
// Handle the failure gracefully
showNotification('Storage full. Preferences not saved.');
// Maybe save to server instead
savePreferencesToServer(preferences);
}

Checking Storage Before Writing

I also wanted to know how much space I had left. Here’s a helper that estimates available storage:

storage-check.js
/**
* Estimate localStorage usage
* @returns {object} - Usage statistics
*/
function getStorageStats() {
let totalSize = 0;
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
const value = localStorage.getItem(key);
// Each character is 2 bytes in UTF-16
totalSize += (key.length + value.length) * 2;
}
}
// Convert to KB
const sizeKB = totalSize / 1024;
// Most browsers allow ~5 MB (5120 KB)
const quotaKB = 5120;
const percentUsed = (sizeKB / quotaKB) * 100;
return {
usedKB: Math.round(sizeKB),
quotaKB: quotaKB,
percentUsed: Math.round(percentUsed),
availableKB: Math.round(quotaKB - sizeKB)
};
}
// Usage
const stats = getStorageStats();
if (stats.percentUsed > 80) {
console.warn(`Storage is ${stats.percentUsed}% full. Consider clearing old data.`);
}

Handling the Inevitable Cleanup

When storage is full, you need strategies to clear space. Here’s an LRU (Least Recently Used) approach:

storage-cleanup.js
/**
* Store data with timestamp for LRU eviction
*/
function setWithTimestamp(key, value) {
const data = {
value: value,
timestamp: Date.now()
};
return safeSetItem(key, data);
}
/**
* Clear oldest entries to make space
* @param {number} bytesToFree - Approximate bytes to free
*/
function clearOldestEntries(bytesToFree = 102400) { // Default: 100KB
const entries = [];
// Collect all entries with timestamps
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
try {
const data = JSON.parse(localStorage.getItem(key));
entries.push({
key: key,
timestamp: data.timestamp || 0,
size: (key.length + localStorage.getItem(key).length) * 2
});
} catch (e) {
// Not JSON, skip
}
}
}
// Sort by timestamp (oldest first)
entries.sort((a, b) => a.timestamp - b.timestamp);
// Remove entries until we've freed enough space
let freed = 0;
for (const entry of entries) {
if (freed >= bytesToFree) break;
localStorage.removeItem(entry.key);
freed += entry.size;
console.log(`Removed old entry: ${entry.key}`);
}
return freed;
}

Best Practices I Now Follow

After this incident, I implemented these rules for my team:

  1. Never assume localStorage works - Always check availability first
  2. Always use try-catch - Wrap every setItem() call
  3. Set size limits - Don’t store unlimited data without cleanup
  4. Have a fallback - Use IndexedDB or server-side storage for larger data
  5. Clear proactively - Don’t wait for quota errors to clean up
  6. Monitor usage - Warn users when approaching limits

Quick Checklist

Before deploying code that uses localStorage, verify:

  • All setItem() calls have try-catch blocks
  • There’s a fallback when storage fails (graceful degradation)
  • You’re not storing large binary data (use IndexedDB instead)
  • Old data gets cleaned up regularly
  • - [ ] You’ve tested with full storage (I simulate this by filling localStorage in DevTools)

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