Where the browser keeps things

Put each piece of state where its lifetime, size and audience say it belongs.

16of 18
≈ 12Min read
5Parts

Five places, and they are not interchangeable

The column that decides most designs is the fourth. A cookie is the only one the browser attaches to requests automatically — which is why sessions live there, and why putting a 3 KB preferences blob in one adds 3 KB to every single request, including every image.

StoreSizeLivesSent to the serverReadable by script
Cookie~4 KB eachuntil it expireson every requestunless HttpOnly
localStorage~5–10 MBforever, until clearednoyes
sessionStorage~5–10 MBthat tab onlynoyes
IndexedDBhundreds of MBforevernoyes
Cache APIshares the quotaforevernoyes (from a service worker too)

localStorage is synchronous, and that matters

Every read and write is synchronous: the event loop stops. That is fine for a theme name and wrong for anything large, which is what IndexedDB is for — asynchronous, structured, and able to store objects, blobs and files without serialising them by hand.

it blocks the thread, and it only holds strings
localStorage.setItem("theme", "dark");
localStorage.getItem("theme");        // "dark"
localStorage.setItem("prefs", JSON.stringify(prefs));   // objects must be serialised

// reading a megabyte of JSON here parses it on the main thread, before your first paint
Never a token, never a secret

Any script on the page reads localStorage in one line — an injected one, or one from a compromised dependency. The auth lesson's rule stands: an HttpOnly cookie, or nothing.

Quotas, eviction and the truth about "forever"

A browser gives an origin a share of the disk — typically a percentage of what is free — across IndexedDB, the Cache API and the rest together. When space runs short, whole origins are evicted, usually least-recently-used first. Safari clears script-writable storage for sites the user has not visited in seven days.

So: treat every client-side store as a cache that can vanish, and keep the source of truth on the server. navigator.storage.persist() asks for an exemption, and the browser decides.

how much have you got
const { usage, quota } = await navigator.storage.estimate();
const persistent = await navigator.storage.persist();   // a request, not a command

Choosing, in four questions

  • Does the server need it on every request? → a cookie, and keep it small.
  • Is it a secret or an identity? → an HttpOnly cookie set by the server. Never a script-readable store.
  • Is it more than a few kilobytes, or not a string? → IndexedDB.
  • Should it disappear when the tab closes? → sessionStorage.
  • Everything else — a theme, a collapsed sidebar, a draft — is localStorage, and it is the right answer more often than the list suggests.

In this lab, and in this sandbox

the shape that survives everywhere
const safeGet = (key, fallback = null) => {
  try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
};

const safeSet = (key, value) => {
  try { localStorage.setItem(key, value); return true; } catch { return false; }
};
Why the lab does not call localStorage

Every practice frame on this site runs with an opaque origin — a sandbox with no allow-same-origin — and in that context localStorage throws on access rather than returning null. That is the same restriction a page gets inside a sandboxed iframe on any site, and it is worth knowing: code that assumes storage exists breaks there, in a private window, and wherever site data is blocked. Wrap every access in try/catch and have a fallback.

Practice