Tags: web-dev concept

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 localStorage for 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.

CapacityLifetimeAPISent to server
Cookies~4KBSet by attributeSync, stringYes, every request
localStorage~5–10MBUntil clearedSync, stringNo
sessionStorage~5–10MBUntil tab closesSync, stringNo
IndexedDBLarge, quota-basedUntil clearedAsync, structuredNo
Cache APILarge, quota-basedUntil clearedAsync, Request/ResponseNo

[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 want
  • localStorage — 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. An HttpOnly cookie is invisible to JavaScript; localStorage is 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.com and https://www.example.com have 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 localStorage with 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, not basket. Third parties share the store and collisions are silent
  • Version your schema. Stored shapes outlive deployments; a v key 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