Introduction

Safari on iOS has strict localStorage limitations that cause QuotaExceededError when writing data, particularly in Private Browsing mode where localStorage is completely disabled. The error manifests as:

javascript
Uncaught QuotaExceededError: The quota has been exceeded.
  at localStorage.setItem('app-state', JSON.stringify(state))

This breaks applications that rely on localStorage for session state, preferences, or cached data, causing crashes on iOS devices.

Symptoms

  • Application crashes on Safari iOS with QuotaExceededError
  • Features work on desktop browsers but fail on iPhone/iPad
  • Private Browsing mode in Safari completely blocks localStorage writes
  • Error occurs even with small amounts of data (less than 1MB)
  • typeof localStorage returns "object" but any setItem call throws

Common Causes

  • Safari Private Browsing mode sets localStorage quota to 0 bytes
  • Safari iOS enforces a 5MB per-origin limit (vs 10MB in Chrome)
  • Storing large JSON objects or base64-encoded images in localStorage
  • Accumulated data from long-term usage exceeding the quota over time
  • Multiple tabs writing to the same key simultaneously

Step-by-Step Fix

  1. 1.Detect localStorage availability before using it:
  2. 2.```javascript
  3. 3.function isLocalStorageAvailable() {
  4. 4.try {
  5. 5.const testKey = '__storage_test__';
  6. 6.localStorage.setItem(testKey, testKey);
  7. 7.localStorage.removeItem(testKey);
  8. 8.return true;
  9. 9.} catch (e) {
  10. 10.return false;
  11. 11.}
  12. 12.}
  13. 13.`
  14. 14.Implement a storage abstraction with fallback:
  15. 15.```javascript
  16. 16.class SafeStorage {
  17. 17.constructor() {
  18. 18.this.available = isLocalStorageAvailable();
  19. 19.this.memory = new Map();
  20. 20.}

setItem(key, value) { if (this.available) { try { localStorage.setItem(key, value); } catch (e) { if (e.name === 'QuotaExceededError') { this.clearOldestItems(); localStorage.setItem(key, value); } } } else { this.memory.set(key, value); } }

getItem(key) { if (this.available) { return localStorage.getItem(key); } return this.memory.get(key) || null; }

clearOldestItems() { // Remove oldest 25% of items const keys = Object.keys(localStorage); const removeCount = Math.floor(keys.length * 0.25); for (let i = 0; i < removeCount; i++) { localStorage.removeItem(keys[i]); } } }

const storage = new SafeStorage(); ```

  1. 1.Use IndexedDB for larger data as an alternative:
  2. 2.```javascript
  3. 3.function openDatabase() {
  4. 4.return new Promise((resolve, reject) => {
  5. 5.const request = indexedDB.open('app-db', 1);
  6. 6.request.onupgradeneeded = (e) => {
  7. 7.e.target.result.createObjectStore('cache');
  8. 8.};
  9. 9.request.onsuccess = (e) => resolve(e.target.result);
  10. 10.request.onerror = (e) => reject(e.target.error);
  11. 11.});
  12. 12.}

async function storeLargeData(key, value) { const db = await openDatabase(); const tx = db.transaction('cache', 'readwrite'); tx.objectStore('cache').put(value, key); return tx.complete; } ```

  1. 1.Compress data before storing to reduce localStorage usage:
  2. 2.```javascript
  3. 3.import { compress, decompress } from 'lz-string';

storage.setItem('app-state', compress(JSON.stringify(appState))); const state = JSON.parse(decompress(storage.getItem('app-state'))); ```

Prevention

  • Never assume localStorage is available - always wrap access in try/catch
  • Set a per-key storage budget (e.g., 1MB max per key) and enforce it
  • Use IndexedDB for data exceeding a few hundred kilobytes
  • Implement automatic cleanup of stale cached data
  • Monitor storage usage: JSON.stringify(localStorage).length to track total bytes used
  • Test your application in Safari Private Browsing mode during QA
  • Consider using session-based storage (sessionStorage) for transient data instead of localStorage