Concept
Browsers offer four meaningfully different client-side storage mechanisms. Picking the right one is a real engineering decision, not a style preference, they differ in capacity, persistence, synchronous vs. async access, and critically, whether the server sees the data automatically.
The comparison that matters
| Capacity | Persistence | Sent to server? | Sync/Async | Structured data | |
|---|---|---|---|---|---|
| Cookies | ~4KB | Configurable expiry | Yes, every matching request | Sync (via document.cookie) | Strings only |
localStorage | ~5-10MB | Until explicitly cleared | No | Sync | Strings only (must JSON.stringify) |
sessionStorage | ~5-10MB | Until tab closes | No | Sync | Strings only |
| IndexedDB | Large (browser-dependent, often 50%+ of free disk) | Until explicitly cleared | No | Async | Structured objects, indexes, transactions |
localStorage / sessionStorage
localStorage.setItem("theme", "dark");
localStorage.getItem("theme"); // "dark"
localStorage.removeItem("theme");
localStorage.clear(); // wipes everything for this origin
// Only strings, objects must be serialized
localStorage.setItem("user", JSON.stringify({ name: "Jane" }));
const user = JSON.parse(localStorage.getItem("user"));localStorage persists indefinitely (until the user clears site data or you remove it programmatically) and is scoped per-origin, shared across all tabs of the same origin. sessionStorage has identical API but is scoped per-tab and cleared when that tab closes (reopening the same URL in a new tab starts fresh, even same-origin).
Both are synchronous, every read/write blocks the main thread. For small amounts of data this is negligible, but storing large payloads (a big cached JSON blob) can cause a measurable jank spike, since it's a blocking main-thread operation with no way to make it async.
// Cross-tab communication: the 'storage' event
window.addEventListener("storage", (e) => {
console.log(e.key, e.oldValue, e.newValue);
// Fires in OTHER tabs when localStorage changes, NOT in the tab that made the change
});Cookies
document.cookie = "theme=dark; max-age=31536000; path=/; SameSite=Lax; Secure";Cookies are the only client storage mechanism automatically sent with every matching HTTP request to the server, that's both their defining feature and the source of most cookie-related bugs and security issues. Key attributes:
HttpOnly, cookie is inaccessible to JavaScript (document.cookiecan't read/write it), only sent over HTTP. Essential for session tokens, makes them immune to theft via XSS, since malicious injected JS simply can't read them.Secure, only sent over HTTPS.SameSite,Strict(never sent cross-site),Lax(sent on top-level navigation, not on cross-site subrequests/iframes, the modern default in most browsers),None(always sent cross-site, requiresSecure). This is the primary defense against CSRF at the cookie level.max-age/, without either, it's a session cookie (cleared when the browser closes).
HttpOnly cookies can only be set by the server (via the Set-Cookie response header), document.cookie in client JS cannot set or read HttpOnly cookies at all, by design.
IndexedDB
const request = indexedDB.open("MyDatabase", 1);
request.onupgradeneeded = (e) => {
const db = e.target.result;
const store = db.createObjectStore("notes", { keyPath: "id" });
store.createIndex("byDate", "createdAt");
};
request.onsuccess = (e) => {
const db = e.target.result;
const tx = db.transaction("notes", "readwrite"
IndexedDB is a full transactional, asynchronous, object database in the browser, structured data (not just strings), indexes for efficient querying, and much higher storage ceilings than localStorage. The raw API is notoriously verbose and callback-heavy (it predates Promises); in practice almost everyone uses a wrapper like idb (a thin Promise-based wrapper by a Chrome engineer) rather than the raw API directly. This is what powers offline-first apps and PWAs, service workers commonly cache structured application data in IndexedDB, separate from the HTTP response caching the Cache API handles.
Choosing the right one
- Need the server to see it automatically on every request? → Cookie (session tokens, auth state the server needs to check on every page load).
- Client-only preference/UI state, small, needs to survive tab close? →
localStorage(theme preference, "don't show this again" flags). - Client-only, scoped to this specific tab/session? →
sessionStorage(a multi-step form's in-progress state, so opening the site in a second tab doesn't collide). - Structured data, larger volume, need querying, or truly offline-capable app data? → IndexedDB.
Common Mistakes
1. Storing sensitive tokens in localStorage
// Common but risky
localStorage.setItem("authToken", jwt);Anything in localStorage is readable by any JavaScript running on the page, including injected malicious scripts from an XSS vulnerability, there's no HttpOnly-equivalent protection. An HttpOnly cookie is immune to this specific attack vector since client-side JS can't read it at all, even if XSS is present. This is a genuine, common security tradeoff discussion, not just theoretical, many real breaches trace back to tokens in localStorage combined with an XSS bug elsewhere in the app.
2. Assuming localStorage writes are synchronized across tabs in the writing tab
// Wrong assumption: this fires in the SAME tab that wrote it
window.addEventListener("storage", handleChange);
localStorage.setItem("key", "value"); // handleChange does NOT fire hereThe storage event only fires in other tabs/windows of the same origin, never in the tab that made the change. If you need same-tab reactivity, that has to be handled directly in the code path that writes the value.
3. Forgetting JSON.parse/stringify round-trips, or forgetting null handling
// getItem returns null (not undefined, not throwing) if the key doesn't exist
const raw = localStorage.getItem("user"); // null if never set
JSON.parse(raw); // throws: "Unexpected token u in JSON at position 0" if raw is null... actually throws differentlyJSON.parse(null) actually coerces to JSON.parse("null") which returns null, but JSON.parse(undefined) throws. The safe pattern is always checking for null/undefined before parsing, or wrapping in try/catch, since malformed/corrupted stored JSON (from an old app version's different shape) will throw.
4. Treating cookies without SameSite as safe by default in older assumptions
Modern browsers default unset SameSite cookies to Lax, but relying on the default rather than being explicit means the behavior silently depends on browser version/vendor, set it explicitly.
5. Hitting storage quota with no error handling
try {
localStorage.setItem(key, largeValue);
} catch (e) {
// QuotaExceededError, happens more often than expected, especially in Safari private browsing
// where localStorage quota is effectively 0
}localStorage.setItem throws QuotaExceededError when the quota is exceeded (or in Safari private browsing mode, where the API exists but the quota is essentially zero), code that assumes setItem never fails will crash unexpectedly for a meaningful subset of real users.
6. Blocking synchronous IndexedDB-shaped thinking
Writing localStorage-style synchronous code patterns against IndexedDB's inherently async API (nested callbacks without a Promise wrapper) produces hard-to-follow, error-prone code. Use a Promise-based wrapper (idb) rather than the raw callback API.
Best Practices
HttpOnlycookies for auth tokens/session identifiers, neverlocalStorage, given XSS exposure.SameSite=Lax(orStrictwhere feasible) +Secureon every cookie that doesn't have a specific reason to beNone.localStoragefor small, non-sensitive, cross-tab-shareable preferences.sessionStoragewhen tab-scoping is actually the desired behavior.- IndexedDB (via a Promise wrapper like
idb) for structured or large client-side data, especially for offline-first/PWA scenarios.
Further Resources
- MDN, Client-side storage overview
- MDN, Web Storage API (localStorage/sessionStorage)
- MDN, IndexedDB API
- MDN, Using HTTP cookies
idb, Promise-based IndexedDB wrapper
