Storage
localStorage, sessionStorage, cookies — quotas, JSON-in-a-string, and what each one is actually for.
You're saving a small piece of state — the user's chosen theme, a draft they're typing, the last filter they used. You don't need a database. You don't need a server round-trip. You just need a place to put a string that survives the page reload. The browser has three of those, with different lifetimes and different gotchas. Pick the right one and the next 200 lines of code write themselves.
This lesson is the cheap end of storage. The next one is IndexedDB — for when "a string under 5 MB" stops being enough.
localStorage
localStorage is a key-value store. Keys and values are both strings. It survives page reloads, tab closes, and browser restarts. It's scoped to the origin (the protocol + host + port), so https://shop.example.com and https://blog.example.com see different stores.
localStorage.setItem("theme", "dark");
const saved = localStorage.getItem("theme"); /* "dark" */
localStorage.removeItem("theme");
localStorage.clear(); /* nuclear option */The whole API is setItem, getItem, removeItem, clear, plus length and key(i) for iteration. getItem returns null when the key is missing — handy as a default fallback.
localStorage is synchronous. Reading or writing blocks the main thread. For a few keys this is fine; for a hot loop or a large blob, reach for IndexedDB instead — it's async and built for volume.
Storing objects
You can only put strings in. So when you want to store an object, you serialize on the way in and parse on the way out.
function saveDraft(draft) {
localStorage.setItem("draft", JSON.stringify(draft));
}
function loadDraft() {
const raw = localStorage.getItem("draft");
if (raw === null) return null;
return JSON.parse(raw);
}A few traps in this little dance:
JSON.stringifyquietly drops things.undefinedvalues, functions, andDateobjects become weird (dates turn into strings; you have to convert them back manually).JSON.parsethrows on bad data. If something else wrote a non-JSON value to that key — or your serialization code crashed mid-write —parseblows up. For non-critical data, wrap it in a try/catch.
function loadDraft() {
const raw = localStorage.getItem("draft");
if (raw === null) return null;
try {
return JSON.parse(raw);
} catch {
localStorage.removeItem("draft"); /* corrupted — drop it */
return null;
}
}This pattern — get, parse, fall back if it's broken — is what most "use localStorage like a small object store" libraries are doing under the hood.
localStorage.setItem("user", { name: "ada" }) directly. What's actually written to disk?sessionStorage
sessionStorage has the exact same API as localStorage. The only difference is its lifetime:
- localStorage persists across tabs, reloads, and browser restarts.
- sessionStorage is per-tab — when the tab closes, it's gone. Reload doesn't clear it; closing does.
Use sessionStorage when the data only matters for the current visit: a wizard's in-progress answers, a one-off "saw the announcement banner" flag, anything you'd lose if the user opened a second tab and you don't want them to see it there.
sessionStorage.setItem("wizardStep", "2");
/* User closes the tab → next visit starts from step 1. */Same API, same gotchas around strings — everything from localStorage carries over.
Cookies are the third option, and they're a different shape. They're sent with every request to the same origin — which is what makes them the natural home for server-readable state like session IDs and authentication tokens. localStorage is invisible to the server; cookies are not.
Two practical takeaways:
- For "remember the user is logged in", servers set a cookie with
HttpOnlyandSecureflags. Your JavaScript shouldn't (and often can't) read it. That's by design — it protects against script-based theft. - For everything else — UI preferences, drafts, "show me the tour again" toggles —
localStorageis the right choice. Don't put data in cookies that the server doesn't need; you'll just inflate every request.
Don't put auth tokens in localStorage. Any script on your origin (a third-party widget, a vulnerability, a misbehaving extension) can read everything in there. Cookies marked HttpOnly are safer because JavaScript literally cannot see them.
Limits and failure modes
localStorage has a quota — usually around 5 MB per origin. When you exceed it, setItem throws a QuotaExceededError. Two situations actually trigger this in real life:
- You're storing a lot of structured data. (At 5 MB, switch to IndexedDB.)
- The user is in private/incognito mode. Some browsers cap private-mode storage tightly or simulate "quota full" the moment you write.
Defensive pattern when storing anything user-facing:
function trySave(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch {
return false; /* over quota or storage disabled */
}
}Returning false lets the caller decide what to do — show a "couldn't save" hint, fall back to in-memory state, or trigger a cleanup. Throwing inside setItem is one of the few times this API surprises you.
localStorage in one tab. The user opens a second tab to the same site. What does the second tab see?Try it yourself
JSON.parse throws because something else corrupted that key. What's the right defensive pattern?