The four places
Storage is per origin, and both Storage APIs are synchronous — reading a large value blocks the thread. For anything beyond preferences and small state, IndexedDB is the right answer.
| Where | Size | Lives until | Sent to the server |
|---|---|---|---|
localStorage | ~5 MB | Explicitly cleared | No |
sessionStorage | ~5 MB | The tab closes | No |
| A cookie | ~4 KB | Its expiry | Yes, on every request |
| IndexedDB | Hundreds of MB | Explicitly cleared | No |
Strings only
localStorage.setItem("count", 5);
localStorage.getItem("count"); // "5" — a string
localStorage.setItem("prefs", JSON.stringify({ theme: "dark" }));
JSON.parse(localStorage.getItem("prefs"));
localStorage.removeItem("prefs");
localStorage.clear();
localStorage.length;
Object.keys(localStorage);
JSON.parse(null) returns null rather than throwing, but JSON.parse(undefined) throws. Default it: JSON.parse(localStorage.getItem(k) ?? "null").
It throws more often than you think
- Safari in private mode used to throw on every write. Some browsers throw on read when storage is disabled.
- The quota is per origin, and exceeding it throws
QuotaExceededError. - The stored JSON may have been written by an older version of your code, or edited by hand. Parsing it can throw.
- A site in a cross-origin iframe may have no storage at all under some privacy settings.
const store = {
get(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback; // blocked, or corrupted JSON
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false; // quota exceeded, or blocked
}
},
remove(key) {
try { localStorage.removeItem(key); } catch { /* blocked */ }
},
};
Versioning what you store
Putting the version in the key means an old value is simply never read. Putting it inside the value lets you migrate it. Either is fine; having neither means a year-old value crashes your new code.
const KEY = "dv.prefs.v2";
function load() {
const saved = store.get(KEY);
if (!saved || saved.v !== 2) return defaults(); // ignore anything older
return saved;
}
store.set(KEY, { v: 2, theme: "dark" });
Reacting to another tab
It does not fire in the tab that made the change — only in the others. That is exactly what you want for keeping a theme or a sign-out in step across tabs.
addEventListener("storage", (e) => {
if (e.key !== "dv.prefs.v2") return;
applyPrefs(JSON.parse(e.newValue ?? "null"));
});
Cookies
- A cookie is sent with every request to that origin, so it costs bandwidth on every page load and every asset.
- A session cookie must be
HttpOnly— set by the server, unreadable by JavaScript. That is what stops a cross-site scripting bug from stealing the session. SameSite=Laxis the sensible default;Strictbreaks links from other sites;NonerequiresSecure.- Use a cookie only when the server needs the value. For anything client-side, use storage.
document.cookie = "theme=dark; path=/; max-age=31536000; SameSite=Lax";
document.cookie; // every readable cookie, as one string
Never store a secret
Anything in localStorage is readable by any JavaScript running on your origin — including a third-party script, and including anything injected through a cross-site scripting bug. An access token in localStorage is a token that a single XSS hands over.
Session identity belongs in an HttpOnly cookie set by the server. Storage is for preferences, drafts and caches — things it would be annoying, not dangerous, to lose.
IndexedDB, in one line
// use it when: the data is large, structured, or queried —
// offline records, cached responses, a document editor's history.
// The raw API is event-based and awkward; idb-keyval is 600 bytes.