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.
| Store | Size | Lives | Sent to the server | Readable by script |
|---|---|---|---|---|
| Cookie | ~4 KB each | until it expires | on every request | unless HttpOnly |
localStorage | ~5–10 MB | forever, until cleared | no | yes |
sessionStorage | ~5–10 MB | that tab only | no | yes |
| IndexedDB | hundreds of MB | forever | no | yes |
| Cache API | shares the quota | forever | no | yes (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.
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
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.
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
HttpOnlycookie 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
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; }
};
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.