
Stop Treating localStorage Like a Database (It Will Bite You in Production)
April 11, 2026
localStorage has a 5-10MB limit, throws QuotaExceededError with no warning, and vanishes in Safari private browsing. I have seen this crash production at three different companies. One was a checkout flow that stored cart state in localStorage, silently dropped items when the quota filled up, and generated weeks of support tickets before anyone traced it to a storage limit.
This post covers the five ways localStorage breaks, what the actual browser limits are, and what to use instead.
The 5 Ways localStorage Breaks in Production#
1. QuotaExceededError With No Graceful Fallback#
Every browser caps localStorage at 5-10MB per origin. When you hit the limit, setItem throws a QuotaExceededError. Most code does not catch this.
I have reviewed dozens of codebases that call localStorage.setItem() without a try-catch anywhere in the call stack.
// Somewhere deep in your analytics module
localStorage.setItem('events', JSON.stringify(massiveEventArray));
// DOMException: Failed to execute 'setItem' on 'Storage':
// Setting the value of 'events' exceeded the quota.
2. Safari Private Browsing Throws on Any Write#
Safari versions before 14 threw a QuotaExceededError on every setItem call in private browsing, even with zero bytes stored. Safari 14+ changed this to allow writes but wipes storage when the tab closes. If your app depends on localStorage persisting across page loads in private browsing, it will not.
3. Synchronous Reads Block the Main Thread#
localStorage is synchronous. Every getItem and setItem call blocks the main thread while the browser reads from or writes to disk. I measured 80-120ms of main thread blocking on a mid-range Android phone reading 2MB from localStorage.
4. No Data Expiration#
localStorage has no TTL. Data lives there until you delete it or the user clears their browser. I have seen origins with 300+ orphaned keys from feature flags, A/B tests, and abandoned experiments, all eating into that 5MB cap.
5. Shared Across Tabs With No Locking#
All tabs on the same origin share one localStorage. Two tabs writing to the same key creates a race condition. The storage event fires in other tabs but not the tab that wrote the change.
If you are using localStorage for cross-tab state, you are building a distributed system with no coordination primitive.
What the Limits Actually Are#
The spec says 5MB but the encoding matters. localStorage stores strings as UTF-16, so each character costs 2 bytes. Your 5MB limit is really ~2.5 million characters of JSON.
localStorage Quota by Browser (MB per origin)
Every major browser enforces 5MB. The old 10MB figure you see in blog posts came from IE, which is dead. The real constraint is that 5MB is shared across your entire origin, including your app, your analytics, and every third-party script.
Warning: Third-party scripts on your origin can fill localStorage without you knowing. I have seen analytics SDKs consume 2MB+ of the 5MB quota before application code wrote a single byte.
The Fix: When to Use What#
Stop defaulting to localStorage. Pick the right tool based on what you are actually storing.
- Session-only state (form drafts, wizard progress): Use
sessionStorage. Same API, same 5MB limit, but it clears when the tab closes. No stale data accumulation. - Large or structured data (offline cache, user content, media): Use IndexedDB. Async, transactional, stores blobs and objects natively. Quota is typically 50-80% of available disk space.
- Auth tokens: Use
httpOnlycookies. localStorage is accessible to any script on the page, including XSS payloads. Cookies withhttpOnlyandSecureflags are invisible to JavaScript. - Server-derived state (user preferences, feature flags): Keep it on the server. Fetch on load, cache in memory with something like Zustand or React context. This is what Next.js cache components solve well.
- Small, non-critical preferences (theme, sidebar collapsed): localStorage is fine here. Just wrap it safely.
A Safe localStorage Wrapper#
If you do use localStorage, wrap every call. This handles quota errors, missing availability, and JSON parse failures in one place.
export const safeStorage = {
get<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
},
set(key: string, value: unknown): boolean {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
if (e instanceof DOMException && e.name === 'QuotaExceededError') {
console.warn(`localStorage quota exceeded for key: ${key}`);
// Optionally: clear old keys, report to monitoring
}
return false;
}
},
remove(key: string): void {
try {
localStorage.removeItem(key);
} catch {
// Private browsing or storage disabled
}
},
isAvailable(): boolean {
try {
const test = '__storage_test__';
localStorage.setItem(test, '1');
localStorage.removeItem(test);
return true;
} catch {
return false;
}
}
};
The set method returns a boolean so callers know if the write succeeded. The isAvailable check catches Safari private browsing and enterprise browsers with storage disabled by policy. Use it at app startup to decide your storage strategy.
Tip: Add a clear(prefix: string) method that removes keys matching a namespace. This prevents the stale-key accumulation problem and keeps your quota usage predictable.Migration Path: localStorage to IndexedDB#
If you already have users with data in localStorage, you cannot just switch. You need to migrate existing data, then stop writing to localStorage. Here is a migration pattern using idb-keyval, a 600-byte wrapper around IndexedDB.
import { get, set, del } from 'idb-keyval';
const MIGRATED_KEY = '__storage_migrated__';
export async function migrateFromLocalStorage(
keys: string[]
): Promise<void> {
const alreadyMigrated = await get<boolean>(MIGRATED_KEY);
if (alreadyMigrated) return;
for (const key of keys) {
const raw = localStorage.getItem(key);
if (raw !== null) {
try {
const parsed = JSON.parse(raw);
await set(key, parsed);
localStorage.removeItem(key);
} catch {
// Corrupt data, skip it
localStorage.removeItem(key);
}
}
}
await set(MIGRATED_KEY, true);
}
Call this once at app startup before any reads. After migration, all new reads and writes go through IndexedDB. The old localStorage keys are cleaned up so they stop eating quota.
- List every localStorage key your app uses. Search your codebase for
localStorage.setItemandlocalStorage.getItem. - Install
idb-keyval:npm install idb-keyval(600 bytes gzipped, no config needed). - Run the migration function at app startup, before your first storage read.
- Replace all
localStorage.*calls withidb-keyvalequivalents (get,set,del). - Monitor for
QuotaExceededErrorin your error tracker for 2 weeks, then remove the migration code.
TL;DR: localStorage is fine for small, non-critical preferences. For anything else, use sessionStorage (ephemeral), IndexedDB (large/structured), or server state. Always wrap localStorage in a try-catch. Migrate existing data with idb-keyval before switching.