Skip to main content

Browser storage: cookie, LocalStorage, SessionStorage and IndexedDB

Browser storage is the collective name for four browser APIs (cookies, LocalStorage, SessionStorage, IndexedDB) that let your app keep data on the client side, each with different size limits, lifetime rules, and one key split: whether data travels to the server automatically or stays local.

Theory

TL;DR

  • Cookies are like postcards: small, stamped, and sent with every HTTP request whether you want it or not
  • LocalStorage and SessionStorage are notebooks in a drawer: Local stays until you delete it, Session disappears when the tab closes
  • IndexedDB is a filing cabinet: async, indexed, and built for large structured data
  • Decision rule: server needs the data? Use a cookie. Tab-only state? SessionStorage. Persistent key-value? LocalStorage. Offline app with real data? IndexedDB.

Quick example

js
// Cookies travel with every HTTP request automatically document.cookie = "userId=123; path=/; max-age=3600"; // LocalStorage: persists across tabs and restarts, ~5-10MB localStorage.setItem("theme", "dark"); console.log(localStorage.getItem("theme")); // "dark" // SessionStorage: same tab only, cleared on close sessionStorage.setItem("cart", JSON.stringify({ id: 1 })); // Open the Network tab in DevTools: only the cookie appears in request headers // Cookie: userId=123

Only cookies show up in request headers. The other three stay off the wire entirely.

Key difference

Cookies attach to every HTTP request automatically. That is both their power and their cost: you get server-side auth state without any extra code, but you pay in bandwidth and a ~4KB per-cookie cap. LocalStorage, SessionStorage, and IndexedDB never touch the network. They live in origin-scoped storage on disk, which makes reads fast and keeps data private from your server unless you explicitly send it.

When to use

  • Auth tokens and sessions: cookies with HttpOnly and Secure flags. JS cannot read an HttpOnly cookie, which blocks XSS from stealing tokens.
  • User preferences (theme, language): LocalStorage. Persists across sessions, syncs across tabs via the storage event.
  • Form draft in one tab: SessionStorage. Auto-clears on close, no manual cleanup needed.
  • Offline app data (messages, todos, product catalog): IndexedDB. Supports transactions, indexes, cursors, and binary blobs.
  • Caching images or files client-side: IndexedDB again. It is the only option that handles binary data at scale.

Comparison table

StorageSize limitScopeLifetimeSent to server?API
Cookie~4KB per cookieDomain + pathExpires / Max-AgeYes, every requestdocument.cookie
LocalStorage~5-10MB per originOriginUntil deletedNolocalStorage
SessionStorage~5-10MB per originOrigin + tabTab/window closeNosessionStorage
IndexedDB100MB+ (disk quota)OriginUntil deletedNoAsync DB API

How the browser handles this internally

Browsers store LocalStorage and SessionStorage as SQLite-backed files keyed by origin. The rendering engine (Blink in Chrome, Gecko in Firefox) enforces the ~10MB quota. Cookies live in a per-domain cookie jar that the network stack reads before every fetch or XHR, serializing them into the Cookie: header before the request leaves the machine. IndexedDB in Chromium uses LevelDB under the hood and has a dynamic quota tied to available disk space, which can reach 50% of total disk in some browsers.

Common mistakes

Storing JWTs in LocalStorage:

js
// Wrong: any injected script on the page can read this localStorage.setItem("authToken", "eyJhbGci..."); // Correct: set via server response header, invisible to JS // Set-Cookie: token=eyJhbGci...; HttpOnly; Secure; SameSite=Strict

LocalStorage is accessible to any JavaScript on the page. One XSS vulnerability and the token is gone.

Assuming LocalStorage updates propagate instantly across tabs:

js
// Tab A writes: localStorage.setItem("count", "1"); // Tab B reads the old value immediately // You need the storage event: window.addEventListener("storage", e => { console.log("Updated in another tab:", e.newValue); });

The storage event fires in other tabs, not the one that wrote. SessionStorage does not fire it at all.

Not bumping the IndexedDB version on schema change:

js
// Wrong: version stays at 1 after adding a new object store indexedDB.open("myDB", 1); // onupgradeneeded never fires again // Correct: indexedDB.open("myDB", 2); // triggers onupgradeneeded for migration

The onupgradeneeded callback only runs when the version number increases.

Using SessionStorage for multi-tab flows: Each tab gets its own isolated SessionStorage. If a user opens a second tab mid-checkout, the cart from the first tab is not there. Use LocalStorage or BroadcastChannel API for cross-tab state.

Writing many cookies from the client: document.cookie parsing is synchronous. On pages with 50+ cookies, the string manipulation adds up. Prefer setting cookies server-side via Set-Cookie headers and only reading them client-side when needed.

Real-world usage

  • next-themes (React/Next.js): stores the active theme in LocalStorage, reads it on first render to avoid flash of wrong theme
  • Redux Persist: serializes Redux state to LocalStorage or IndexedDB for offline-capable apps
  • Auth0 / Okta: HttpOnly cookies for JWTs, LocalStorage only for non-sensitive metadata
  • PouchDB: wraps IndexedDB to give a CouchDB-style API that syncs to a server when back online
  • Shopify Storefront: SessionStorage for guest cart in a single tab, IndexedDB for offline catalog browsing

Follow-up questions

Q: What is the security difference between storing a JWT in LocalStorage vs. a cookie?
A: A cookie with HttpOnly and Secure flags is not accessible to JavaScript at all, so XSS cannot read it. LocalStorage is fully JS-accessible. Short expiry plus refresh tokens can reduce the window, but an HttpOnly cookie is the safer default for auth tokens.

Q: How does IndexedDB quota work compared to LocalStorage?
A: LocalStorage has a hard cap around 5-10MB per origin. IndexedDB quota is dynamic: browsers typically allow up to 50% of available disk space, but the storage is best-effort and can be evicted. Use navigator.storage.persist() to request durable storage and navigator.storage.estimate() to check current usage.

Q: When does the storage event fire, and for which storages?
A: It fires in other tabs and windows of the same origin when LocalStorage changes. It does not fire in the tab that made the change. SessionStorage changes never trigger this event because SessionStorage is isolated per tab by design.

Q: How do you migrate data from LocalStorage to IndexedDB without downtime?
A: Read LocalStorage keys during app init, write them into an IndexedDB transaction in the background, then delete the LocalStorage entries. Keep a versioned flag like migratedV2: true so the migration only runs once. Users mid-session see no interruption.

Q (senior): How does each storage behave in private/incognito mode, and what does navigator.storage.estimate() return there?
A: All storages work during the session but are discarded on window close. Cookies without Expires or Max-Age become session cookies and disappear. In some browsers the IndexedDB quota in incognito is capped lower than normal mode. navigator.storage.estimate() still returns values, but they reflect an ephemeral in-memory store, not real disk. navigator.storage.persist() in incognito always returns false.

Examples

Theme persistence with LocalStorage in React

js
// Saves theme preference across page reloads with no server round-trip const useTheme = () => { const [theme, setTheme] = useState( () => localStorage.getItem("theme") || "light" // lazy init avoids SSR mismatch ); useEffect(() => { localStorage.setItem("theme", theme); document.body.className = theme; }, [theme]); return { theme, toggleTheme: () => setTheme(t => t === "light" ? "dark" : "light"), }; }; // After reload: localStorage.getItem("theme") → "dark", no network request

The lazy initializer in useState runs only once. Without it you would read LocalStorage on every render, which causes issues with server-side rendering.

IndexedDB with transactions (cart example)

js
const request = indexedDB.open("cartDB", 1); request.onupgradeneeded = e => { const db = e.target.result; db.createObjectStore("items", { keyPath: "id", autoIncrement: true }); }; request.onsuccess = e => { const db = e.target.result; const tx = db.transaction("items", "readwrite"); const store = tx.objectStore("items"); // Both writes are inside one transaction: either both commit or both roll back store.add({ item: "apple", qty: 2 }); store.add({ item: "banana", qty: 1 }); tx.oncomplete = () => console.log("Cart saved"); tx.onerror = () => console.error("Write failed, possibly disk full"); };

Without a transaction, two sequential store.add() calls can race under load. The transaction guarantees atomicity.

js
// Insecure: token exposed to any JS on the page localStorage.setItem("token", "eyJhbGci..."); // Secure: set by server, invisible to JS // Server sends: Set-Cookie: token=eyJhbGci...; HttpOnly; Secure; SameSite=Strict // Client just calls the API, browser attaches cookie automatically const res = await fetch("/api/me"); const user = await res.json(); console.log(user.name); // token never touched by client-side JS

I have seen teams switch from LocalStorage tokens to HttpOnly cookies after a pen test flagged XSS exposure. The migration takes one afternoon. The peace of mind lasts much longer.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?