The 3am version of this story is always the same shape: something got stored in the wrong place. A cart that vanished because it lived in sessionStorage and the user closed the tab. An auth token sitting in localStorage that an XSS bug quietly exfiltrated. A cookie so large it pushed every single request over a proxy's header-size limit. Four APIs, four very different failure modes — and the browser will happily let you use any of them for anything.
The four options, side by side
| Cookies | localStorage | sessionStorage | IndexedDB | |
|---|---|---|---|---|
| Capacity | ~4KB | ~5–10MB | ~5–10MB | Hundreds of MB+ |
| Persistence | Set by Expires/Max-Age, or session | Until explicitly cleared | Until tab closes | Until explicitly cleared |
| Sent to server automatically | Yes, on every matching request | No | No | No |
| API style | String header, manual parsing | Synchronous, key-value strings | Synchronous, key-value strings | Asynchronous, structured objects |
| Shared across tabs | Yes | Yes | No (per-tab) | Yes |
That "sent to server automatically" row is the one people forget, and it's the source of most of the surprises below.
Cookies: the only one the server sees
Cookies are the odd one out — every cookie that matches a request's domain and path gets attached to that request automatically, which is exactly why they're still the right tool for session identifiers and auth tokens. Pair HttpOnly (blocks JavaScript from reading it, which blunts XSS) with Secure (HTTPS only) and SameSite (controls cross-site sending, your main CSRF defense), and you get a storage mechanism the server can trust without any extra plumbing. The cost is the tiny ~4KB budget and the fact that every cookie rides along on every matching request — stash something large in a cookie and you've just added latency to your entire site.
localStorage: convenient, and that's the problem
localStorage is a synchronous key-value string store that survives tab closes, browser restarts, even reboots — nothing clears it except an explicit .removeItem(), .clear(), or the user wiping site data. That persistence is exactly why it's tempting to drop an auth token in there... and exactly why you shouldn't. Anything in localStorage is readable by any JavaScript running on the page, including a malicious script from a successful XSS injection or a compromised third-party dependency. A cookie with HttpOnly set is invisible to that same script; localStorage has no equivalent flag. Use it for UI preferences, cached non-sensitive API responses, feature-flag state — not credentials.
sessionStorage: same API, opposite lifetime
sessionStorage shares localStorage's synchronous string-only API but clears the moment the tab closes, and — this is the part that catches people — it is not shared across tabs, even two tabs on the exact same origin. Open your app in a second tab and sessionStorage starts empty. It's the right call for data that should genuinely die with the tab: a multi-step form's in-progress state, a one-time redirect target, anything you'd be fine losing if the user just closes the window.
IndexedDB: the one built for real data
IndexedDB is a full asynchronous, transactional, indexed database running in the browser — structured objects (not just strings), hundreds of megabytes of headroom depending on available disk space, and query support via indexes. The tradeoff is the API: it's callback/promise-based and noticeably more verbose than localStorage.setItem(), which is why most teams reach for a wrapper library (Dexie, idb) rather than the raw API. Reach for it when you're storing anything structured or sizeable — offline-first app data, cached datasets, file blobs — where localStorage's 5–10MB string-only ceiling won't cut it.
The decision in one pass
- Does the server need to see it on every request? → Cookie.
- Is it a credential or anything sensitive? → Cookie with
HttpOnly+Secure+SameSite, neverlocalStorage. - Should it die when the tab closes, and only that tab? →
sessionStorage. - Is it structured, large, or does it need querying? → IndexedDB.
- Everything else — small, non-sensitive, should survive a refresh →
localStorage.
Building or debugging cookies
If cookies are the piece you're working with, our Cookie Builder assembles a spec-correct Set-Cookie header from a form — name, value, domain, path, expiry, and all the security flags — so you don't hand-write the semicolon-separated syntax. And when you're staring at a raw Cookie: or Set-Cookie: header from DevTools or a curl -v dump, the Cookie Parser breaks it into a readable table, flags whether Secure/HttpOnly/SameSite are actually set, and decodes any percent-encoded values. Both run entirely client-side.
