FSD Frontend System Design

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.

  • Time12 min
  • DiagramKey-value box with limits and the sync-blocking cost
  • Chapter6 Β· Database & Caching

Diagram

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.14
diagram
THE 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.
diagram
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

  1. setItem / getItem / removeItem / clear, all synchronous, all strings.
  2. Serialise objects yourself with JSON.stringify, and always parse defensively β€” the stored value may be from an older version of your app.
  3. Listen to the storage event to keep multiple tabs in sync.
  4. Wrap every access in try/catch. Safari private mode and quota exhaustion both throw on write.
  5. Namespace keys (app:v2:theme) so a schema change can be detected and cleaned up.
js
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 forNever use for
Theme, locale, layout preferenceAuth tokens (XSS reads them β€” 3.5)
Feature-flag overrides in devAnything over a few hundred KB
Last-visited route, dismissed bannersPII (it persists indefinitely)
Small, cheap-to-lose stateData 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.