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.
Strictblocks cookies on cross-site navigations;Laxallows top-level GET navigations (default in modern browsers);NonerequiresSecureand 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.comunless 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
storageevents — 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
onupgradeneededto 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=LaxorStrictunless a cross-site OAuth redirect truly needsNone.
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
localStoragebecause the fetch API "cannot send cookies" — fix cookiecredentials: 'include'and CORS instead. - Serializing entire Redux stores to localStorage on every keystroke — debounce and schema-version your snapshots.
- Assuming
sessionStorageis secret — it is still readable by any script on the page. - Opening IndexedDB without handling
onblockedduring 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 —
setItemthrowsQuotaExceededErrorwhen 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
- JWT explained — signing, validation, and where tokens should live
- Content Security Policy explained — reducing XSS risk that exposes storage
- CORS explained — cookies and credentials across origins
- All Solana Garden guides