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
QuotaExceededError: The quota has been exceeded.
at localStorage.setItem('app-data', jsonString)
in Safari on iOSiOS 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
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.