Part III β The Speed Β· Lesson 6.2
Local Storage
In one lineFive megabytes of persistent strings, read and written synchronously on the main thread.
Where you've seen itThe theme toggle. One key, one value, still correct three weeks later.
Diagram
localStorage β per origin, forever, strings only
βββββββββ https://app.example.com ββββββββββ
β "theme" β "dark" β
β "lastRoute" β "/orders" β
β "onboarding" β "{\"step\":3}" β objects must be
ββββββββββββββββββββββββββββββββββββββββββββ JSON.stringify'd
~5MB total Β· survives tab close, browser restart, reboot
Separate store per ORIGIN (scheme + host + port) β see 3.14THE SYNCHRONOUS COST β it blocks the main thread
main thread ββ[render]ββ[localStorage.setItem 8ms]ββ[render]βββΊ
β²
nothing else runs, including input handling
Small values: negligible.
Large JSON blobs, or writes on every keystroke: measurable INP damage.CROSS-TAB SYNC β the storage event, free of charge
Tab A Tab B
β setItem('theme','dark') β
βββββββββββββββββββββββββββββΊβ window.onstorage fires
β β (fires in OTHER tabs, never in
β β the one that wrote it)How it works
setItem/getItem/removeItem/clear, all synchronous, all strings.- Serialise objects yourself with
JSON.stringify, and always parse defensively β the stored value may be from an older version of your app. - Listen to the
storageevent to keep multiple tabs in sync. - Wrap every access in try/catch. Safari private mode and quota exhaustion both throw on write.
- Namespace keys (
app:v2:theme) so a schema change can be detected and cleaned up.
const store = {
get(key, fallback) {
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; }
catch { return fallback; } // corrupt or unavailable
},
set(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); }
catch { /* quota exceeded or private mode β degrade silently */ }
},
};Trade-offs
| Use for | Never use for |
|---|---|
| Theme, locale, layout preference | Auth tokens (XSS reads them β 3.5) |
| Feature-flag overrides in dev | Anything over a few hundred KB |
| Last-visited route, dismissed banners | PII (it persists indefinitely) |
| Small, cheap-to-lose state | Data you can't regenerate |
Quota reality: ~5MB per origin, shared across all keys, and setItem throws QuotaExceededError when full. There's no eviction policy you control β it just fails.
Interview angle
"Why not keep the whole Redux store in localStorage?"
- It's synchronous, so serialising a large store on every change blocks the main thread and hurts INP.
- 5MB is a hard ceiling with no eviction, and writes throw once you hit it.
- Persist a small deliberate slice (preferences, draft ids) and use IndexedDB for anything substantial.
Recap
- Persistent, synchronous, string-only, ~5MB, per origin.
- Wrap in try/catch and parse defensively β it will contain old or invalid data.
- Small preferences only; anything larger belongs in IndexedDB.