Guide

Browser storage explained: cookies, localStorage, and IndexedDB

Modern web apps persist data in the browser for sessions, preferences, drafts, and offline caches. Browsers expose several storage APIs — each with different size limits, security properties, and sync behavior. Picking the wrong one leads to subtle bugs: tokens stolen by XSS, cookies blocked on cross-site requests, or multi-megabyte JSON choking the main thread. This guide explains how HTTP cookies, the Web Storage API (localStorage and sessionStorage), and IndexedDB work, when to use each, and the security patterns that keep user data safe in production SPAs and wallet-connected dApps.

Why browsers offer multiple storage layers

The web was designed as a stateless document platform. Cookies were added early so servers could correlate requests from the same browser. As JavaScript apps grew richer, developers needed larger, structured client-side stores without sending everything back on every HTTP request. The result is a stack of APIs with overlapping but distinct roles:

  • Cookies — small key/value pairs automatically attached to HTTP requests; ideal for session identifiers the server must read.
  • localStorage / sessionStorage — simple string key/value stores scoped to an origin; easy API, synchronous, not sent to the server by default.
  • IndexedDB — asynchronous database for objects, blobs, and indexes; supports transactions and much larger quotas.

All of these are partitioned by origin (scheme + host + port). https://app.example.com cannot read storage from https://evil.example.com, even on the same registrable domain, unless you explicitly share state via subdomains and cookie Domain attributes — which has security trade-offs. Understanding origin boundaries pairs naturally with CORS when your API lives on a different host than your frontend.

HTTP cookies: automatic request baggage

Cookies are set by the server via Set-Cookie response headers (or by JavaScript through document.cookie, with restrictions). On subsequent requests to matching domains and paths, the browser attaches matching cookies in the Cookie header — no application code required. That automatic round-trip is both the feature and the risk.

Essential cookie attributes

  • HttpOnly — JavaScript cannot read the cookie. Use this for session IDs and refresh tokens so a single XSS bug cannot exfiltrate them.
  • Secure — cookie is sent only over HTTPS. Required for production auth cookies.
  • SameSite — controls cross-site sending. Strict blocks cookies on cross-site navigations; Lax allows top-level GET navigations (default in modern browsers); None requires Secure and allows third-party contexts (embedded iframes, some OAuth flows).
  • Max-Age / Expires — session cookies die when the browser closes; persistent cookies survive restarts until expiry.
  • Path / Domain — limit which URLs receive the cookie. Avoid overly broad Domain=.example.com unless every subdomain is equally trusted.

Practical size limit is about 4 KB per cookie, and browsers cap total cookies per domain. Store opaque session identifiers in cookies, not entire JWT payloads — large tokens blow the limit and leak claims to every request. Prefer server-side sessions or short-lived access tokens in memory with HttpOnly refresh cookies.

localStorage and sessionStorage

The Web Storage API exposes two nearly identical interfaces: localStorage persists until explicitly cleared; sessionStorage survives page reloads but is wiped when the tab closes. Both store UTF-16 strings keyed by origin, expose synchronous getItem/setItem, and typically allow 5–10 MB per origin (browser-dependent).

Good fits

  • UI preferences (theme, sidebar collapsed, last-selected tab)
  • Non-sensitive draft text the user expects to survive refresh
  • Feature flags or A/B bucket IDs with no security impact
  • Client-side cache keys pointing to server-fetched data

Bad fits

  • Authentication tokens — any XSS script can read localStorage
  • Large binary assets — base64-in-JSON wastes space and blocks the main thread
  • Data that must sync across tabs without storage events — possible but awkward
  • Secrets, API keys, or private keys — never, including wallet material

Because reads and writes are synchronous, parsing a megabyte JSON blob on every page load causes jank. For structured or large data, move to IndexedDB. When you hydrate server-rendered HTML with client state, see SSR vs CSR rendering patterns — storing serialized Redux state in localStorage can fight with stale server HTML if you are not careful about version stamps.

IndexedDB: structured, asynchronous storage

IndexedDB is a transactional object store inside the browser. You define object stores (tables) and optional indexes, then read and write via asynchronous requests or modern idb wrapper libraries. Typical per-origin quotas reach hundreds of megabytes (and can grow with user permission on some platforms), making IndexedDB the right choice for offline-first apps, media caches, and large client datasets.

Core concepts

  • Database version — schema changes bump the version number; use onupgradeneeded to create stores and indexes.
  • Transactions — reads and writes happen inside read-only or read-write transactions; they auto-commit or abort on error.
  • Keys — inline keys, key paths on objects, or auto-incrementing counters.
  • Indexes — query by fields other than the primary key without full scans.

IndexedDB pairs with the Cache API in service workers for progressive web apps: Cache stores Request/Response pairs for offline HTML and assets; IndexedDB holds structured records (messages, cart items, game saves). Both are cleared independently during "clear site data," so design recovery paths that re-sync from the server.

Error handling matters: transactions abort if an unhandled error bubbles. Wrap operations in try/catch, use promise wrappers, and never assume a write succeeded until the transaction completes. For wallet and payment UIs, treat IndexedDB as a convenience cache — authoritative balances still come from chain state and verified API responses.

Security: XSS, CSRF, and token placement

Storage choice is a security decision. Cross-site scripting (XSS) lets attacker-controlled JavaScript run in your origin. That script can read all non-HttpOnly cookies set via JS, all of localStorage, and all IndexedDB data for your site. Mitigate XSS first with output encoding, strict Content Security Policy, and dependency auditing — then minimize blast radius:

  • Keep long-lived refresh tokens in HttpOnly + Secure + SameSite cookies.
  • Hold short-lived access tokens in memory (module closure or React state), not localStorage.
  • Never store seed phrases or private keys in any browser storage API — wallet extensions use their own isolated vaults.
  • Mark sensitive cookies SameSite=Lax or Strict unless a cross-site OAuth redirect truly needs None.

CSRF exploits cookies being sent automatically. If you use cookie-based sessions, protect state-changing endpoints with CSRF tokens, double-submit cookies, or SameSite plus custom headers (Authorization bearer tokens are not sent cross-site by default). SPAs that store JWTs in localStorage and send them via Authorization headers avoid classic CSRF but remain fully vulnerable to XSS — there is no free lunch.

Quota, eviction, and private browsing

Browsers enforce storage quotas per origin. When disk pressure rises, they may evict data from origins the user has not visited recently — especially for Safari and mobile Chrome. Private/incognito windows often wipe all storage when the session ends. Design flows that degrade gracefully: re-fetch from the network, show "offline cache stale" banners, and never assume a local draft exists without confirming.

Use the Storage Manager API (navigator.storage.estimate()) to log usage in diagnostics builds. Request persistent storage (navigator.storage.persist()) only when your app genuinely needs to retain large offline libraries and you can explain why to users.

Choosing the right API

Need Prefer Avoid
Server must authenticate every request HttpOnly session cookie JWT in localStorage
Theme / layout preference localStorage or CSS prefers-color-scheme IndexedDB (overkill)
Multi-MB offline dataset IndexedDB localStorage JSON strings
Tab-scoped wizard state sessionStorage localStorage (leaks across tabs)
Cross-subdomain SSO Carefully scoped cookie Domain + HTTPS postMessage token handoffs without validation

Common mistakes

  • Storing refresh tokens in localStorage because the fetch API "cannot send cookies" — fix cookie credentials: 'include' and CORS instead.
  • Serializing entire Redux stores to localStorage on every keystroke — debounce and schema-version your snapshots.
  • Assuming sessionStorage is secret — it is still readable by any script on the page.
  • Opening IndexedDB without handling onblocked during schema upgrades — old tabs stall migrations.
  • Using cookies for analytics IDs without SameSite — breaks in embedded contexts and annoys privacy tools.
  • Ignoring storage quota errors — setItem throws QuotaExceededError when full.

Starter checklist

  • Auth refresh tokens: HttpOnly, Secure, tight Max-Age, SameSite matched to your OAuth/cross-site needs.
  • Access tokens: memory-only where possible; short TTL.
  • Preferences: localStorage with namespaced keys (app:v2:theme).
  • Large or indexed data: IndexedDB with versioned schema migrations.
  • CSP and XSS defenses deployed before debating token storage.
  • Graceful fallback when storage is unavailable (Safari private mode, quota exceeded).
  • Never persist wallet seeds, private keys, or unencrypted PII.

Related reading