The Problem

Mobile Safari on iOS has a strict localStorage quota (typically 5MB). When the quota is exceeded, any attempt to write to localStorage throws an error. In Private Browsing mode, the quota may be 0 bytes.

Symptoms

  • App crashes with "QuotaExceededError" on iOS Safari
  • Works on desktop browsers but fails on mobile Safari
  • Private Browsing mode causes immediate failures
  • App works until localStorage fills up, then breaks

Real Error Message

bash
QuotaExceededError: The quota has been exceeded.
    at localStorage.setItem('app-data', jsonString)
    in Safari on iOS

iOS Safari Storage Limits

  • Normal mode: ~5MB per origin
  • Private Browsing mode: 0 bytes (writes always fail)
  • Unlike Chrome, Safari does not prompt for more storage

How to Fix It

Fix 1: Wrap localStorage Writes in try/catch

javascript
function safeSetItem(key, value) {
  try {
    localStorage.setItem(key, value);
    return true;
  } catch (e) {
    if (e.name === 'QuotaExceededError') {
      console.warn('localStorage quota exceeded');
      // Fall back to in-memory storage
      memoryStore.set(key, value);
      return false;
    }
    throw e;
  }
}

Fix 2: Detect Private Browsing Mode

```javascript function isPrivateBrowsing() { try { localStorage.setItem('__test__', '1'); localStorage.removeItem('__test__'); return false; } catch (e) { return true; // Safari Private Browsing } }

if (isPrivateBrowsing()) { // Use in-memory storage instead window.storageBackend = new Map(); } else { window.storageBackend = { get: (key) => localStorage.getItem(key), set: (key, val) => localStorage.setItem(key, val), remove: (key) => localStorage.removeItem(key) }; } ```

Fix 3: Implement Storage with Eviction

```javascript class SmartStorage { constructor(maxEntries = 50) { this.maxEntries = maxEntries; this.memoryStore = new Map(); }

setItem(key, value) { try { // Check current usage let totalSize = 0; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); totalSize += localStorage.getItem(k).length; }

// If approaching limit, remove oldest entries if (totalSize > 4 * 1024 * 1024) { // 4MB threshold this.evictOldest(); }

localStorage.setItem(key, value); } catch (e) { if (e.name === 'QuotaExceededError') { this.evictOldest(); this.memoryStore.set(key, value); } } }

getItem(key) { return this.memoryStore.get(key) || localStorage.getItem(key); }

evictOldest() { if (localStorage.length > 0) { const oldestKey = localStorage.key(0); localStorage.removeItem(oldestKey); } } } ```

Fix 4: Use IndexedDB as Fallback

```javascript import localforage from 'localforage';

// localforage automatically uses IndexedDB -> WebSQL -> localStorage const store = localforage.createInstance({ name: 'my-app', storeName: 'app_data' });

await store.setItem('user', userData); const user = await store.getItem('user'); ```

Fix 5: Compress Data Before Storing

```javascript import { compressToUTF16, decompressFromUTF16 } from 'lz-string';

function setCompressed(key, data) { const json = JSON.stringify(data); const compressed = compressToUTF16(json); safeSetItem(key, compressed); }

function getCompressed(key) { const compressed = localStorage.getItem(key); if (!compressed) return null; const json = decompressFromUTF16(compressed); return JSON.parse(json); } ```

LZ-String typically achieves 50-70% compression for JSON data.