Back to Browser Internals
Browser Internals
easy
very high
mid

When should you use cookies, localStorage, or sessionStorage?

Cookies are sent on every request and can be HttpOnly/Secure — right for auth tokens. localStorage is JS-readable and persists indefinitely — right for non-sensitive prefs. sessionStorage is JS-readable and dies with the tab — right for ephemeral wizard/draft state.

6 min read·~10 min to think through

All three are key-value string stores in the browser, but they differ on three axes that interviewers care about: lifetime, scope, and how they reach the server. Picking the wrong one is how teams ship XSS-exploitable auth or data that mysteriously vanishes.

Cookies. Up to 4KB per cookie. Sent with every HTTP request to the matching domain (this is both the feature and the cost — wasted bandwidth on every static asset). Configurable via attributes: Domain, Path, Expires/Max-Age, Secure (HTTPS only), HttpOnly (JS cannot read — critical for auth), and SameSite (Lax default, Strict for high-security, None for cross-site with Secure). The only storage option for session/auth tokens that the server reads automatically.

localStorage. ~5–10MB per origin. No expiration — persists until the user (or your code) clears it. JS-readable on the same origin. Synchronous API (getItem/setItem/removeItem) — be careful, large reads block the main thread. Survives tab close, browser restart, and OS reboot.

sessionStorage. Same API and quota as localStorage, but scoped to the tab. A new tab gets a fresh store, even on the same origin. Cleared when the tab closes. Right for "draft form contents while the user is filling it out" or "wizard step state."

Auth: cookies, not localStorage. This is the trap interviewers love. localStorage.setItem("token", jwt) exposes the token to any XSS payload — a single <img onerror> injection exfiltrates the session. HttpOnly cookies are unreadable from JS, so XSS can act as the user but cannot steal a long-lived token. Pair with Secure + SameSite=Lax (or Strict if you don't need cross-site nav-then-POST). For SPAs hitting an API on a different origin, you need SameSite=None; Secure + CORS credentials: 'include' + an explicit allowed origin.

CSRF concern. Auto-sent cookies enable CSRF — a malicious site triggers a request that carries the user's cookie. Defenses: SameSite=Lax (modern default, blocks most cross-site POSTs), CSRF tokens for state-changing routes, double-submit cookie pattern.

Storage events & cross-tab sync. window.addEventListener('storage', ...) fires in other tabs (not the writing tab) when localStorage changes — useful for "logout in one tab logs out all tabs." sessionStorage does not fire across tabs (it's tab-scoped).

Quota. 5MB is a floor, not a guarantee. Quota errors throw on setItem — wrap in try/catch and have a fallback (LRU eviction or IndexedDB for larger payloads).

When to reach for IndexedDB instead. Anything larger than a few hundred KB, structured data, or async access — IndexedDB. Storage APIs like navigator.storage.estimate() give you remaining quota.

Quick decision matrix:

  • Auth tokens / session → HttpOnly Secure cookie (server-set, server-read).
  • User preferences (theme, layout) → localStorage (small, persistent, JS reads).
  • Multi-step form draft → sessionStorage (tab-scoped, auto-cleared).
  • Cached API responses (large, structured) → IndexedDB (or the Cache API for HTTP responses).

Code

ts
export const storage = {
  get<T>(key: string, fallback: T): T {
    try {
      const raw = localStorage.getItem(key);
      return raw ? (JSON.parse(raw) as T) : fallback;
    } catch {
      return fallback;
    }
  },
  set(key: string, value: unknown): boolean {
    try {
      localStorage.setItem(key, JSON.stringify(value));
      return true;
    } catch (e) {
      // QuotaExceededError or serialization failure
      return false;
    }
  },
};
Safe localStorage wrapper with quota + JSON handling
ts
window.addEventListener("storage", e => {
  if (e.key === "auth:loggedIn" && e.newValue === "false") {
    location.href = "/login";
  }
});

export function logout() {
  localStorage.setItem("auth:loggedIn", "false");
  // ...also call the server to clear the HttpOnly cookie
}
Cross-tab logout via storage event

Follow-up questions

  • Why is HttpOnly important even on a site with no XSS today?
  • How does SameSite=Lax differ from Strict?
  • When would you reach for IndexedDB over localStorage?
  • How do you handle quota-exceeded errors?

Common mistakes

  • Storing JWTs in localStorage 'because it's easier' — XSS exfiltration risk.
  • Assuming sessionStorage syncs across tabs (it doesn't).
  • Not catching QuotaExceededError on setItem.
  • Using cookies for large payloads — every request pays the cost.

Performance considerations

  • localStorage is synchronous — large reads block the main thread; offload to IndexedDB.
  • Cookies inflate every request header; keep them small or scope by Path/Domain.

Edge cases

  • Private/incognito mode may give a 0-byte quota or wipe storage on close.
  • Storage event does NOT fire in the same tab that wrote — use a custom event for intra-tab sync.
  • Cookies set with Domain=.example.com leak to all subdomains.

Real-world examples

  • Banking apps: HttpOnly Secure SameSite=Strict cookies for session.
  • Notion: IndexedDB for offline doc cache, localStorage for theme/UI prefs.

Senior engineer discussion

Senior signal: choosing storage by threat model (XSS vs CSRF), quota handling, cross-tab sync via storage events, and knowing when to escalate to IndexedDB.

Related questions