Client Storage
Date: 2026-08-16
Four places to put data in the browser, differing on size, lifetime, synchronicity and who can read them. The common mistake is
localStoragefor everything — it’s synchronous, so it blocks the main thread, and it’s readable by every script on the page.
What it is
Client storage is the set of browser APIs for persisting data on the user’s device, separate from Cookies in that none of it is sent automatically with requests.
| Capacity | Lifetime | API | Sent to server | |
|---|---|---|---|---|
| Cookies | ~4KB | Set by attribute | Sync, string | Yes, every request |
| localStorage | ~5–10MB | Until cleared | Sync, string | No |
| sessionStorage | ~5–10MB | Until tab closes | Sync, string | No |
| IndexedDB | Large, quota-based | Until cleared | Async, structured | No |
| Cache API | Large, quota-based | Until cleared | Async, Request/Response | No |
[CHECK: current quota behaviour — limits are dynamic and per-origin, and differ substantially between engines.]
The synchronous problem
localStorage and sessionStorage are synchronous. Every read and write blocks the main thread — the same thread that renders and handles input.
// blocks rendering while it serialises, writes, and returns
localStorage.setItem('basket', JSON.stringify(largeObject));For a few hundred bytes this is imperceptible. For a large object, serialised and written on every change, it becomes a measurable contributor to long tasks — and it’s invisible in profiling unless you’re looking for it, because it doesn’t look like computation.
Rule of thumb: localStorage for small, infrequently-written values. IndexedDB for anything else.
Choosing
- Cookies — when the server needs the value. Session identifiers, consent state, anything routing or rendering depends on. Costs upload bandwidth on every request
sessionStorage— per-tab state that shouldn’t outlive the visit. A multi-step form’s progress, a scroll position. Two tabs get separate copies, which is usually what you wantlocalStorage— small durable preferences. Theme, dismissed banners, a feature flag override- IndexedDB — structured data of any size, asynchronously. Cached product data, offline queues, a large basket
- Cache API — HTTP responses, managed by a service worker
Everything here is readable
Any script running on the page can read all of it. Every third-party tag, every widget, every injected browser extension. There is no per-script isolation.
Consequences:
- Never store session tokens in
localStorage. AnHttpOnlycookie is invisible to JavaScript;localStorageis not, so an XSS bug that would have been contained becomes account takeover - Never store personal data. Names, emails, addresses, order history — the same exposure as The Data Layer, and the same conclusion: PII in Analytics
- Storage is per-origin.
https://shop.example.comandhttps://www.example.comhave separate stores, which surprises people mid-migration
Storage is not durable
Treat all of it as a cache that can vanish:
- Users clear it, deliberately or via “clear browsing data”
- Private browsing discards everything on close
- Browsers evict under storage pressure, and eviction policy differs by engine
- Tracking prevention deletes it. Safari clears script-writable storage after a period of no interaction with the site, which takes
localStoragewith it — see Browser Privacy Restrictions
That last one matters for analytics: an identifier in localStorage is not more durable than one in a cookie. It’s subject to its own deletion rules, and treating it as a workaround for cookie limits mostly isn’t one — Identity Stitching, User Counting.
Practical
- Wrap access in try/catch. Storage throws when the quota is exceeded and when it’s disabled entirely — Safari private mode historically threw on write, and a top-level throw takes the page with it
- Namespace your keys.
myapp:basket, notbasket. Third parties share the store and collisions are silent - Version your schema. Stored shapes outlive deployments; a
vkey saves an unparseable-object bug later - Set a size budget and check it. Growth here is unbounded and nothing warns you
- Prefer
structuredClone-friendly data in IndexedDB over JSON strings — no serialisation cost, and it stores types JSON can’t