Why Browser Storage Exists — Two Problems, Three Solutions
Browsers ship with three different places to keep data: cookies, localStorage, and sessionStorage. They exist because the web needed to solve two different problems — and the solutions arrived fifteen years apart.
🌐 Problem 1 — HTTP is stateless
Every HTTP request is independent. The server has no memory that you logged in five seconds ago. Cookies (Netscape, 1994) patched this: the server hands the browser a small piece of paper, and the browser shows that paper on every subsequent request to the same site.
📦 Problem 2 — JS apps need local memory
Modern apps need to remember things without bothering the server: theme, draft text, wizard state, cached data. The Web Storage API (HTML5, 2009) gave JS two key-value stores — localStorage (persistent) and sessionStorage (per-tab).
The flow is fundamentally different. Cookies travel with every HTTP request the browser makes — they're a server concern. Web Storage stays inside the browser, only visible to JS — pure client concern. We'll see exactly how cookies move between browser and server in Topic 4.
The Three Mechanisms — Side by Side
Before we dive into each, here's the whole landscape on one page. Every property that matters when you're picking which storage to use.
| Property | Cookies | localStorage | sessionStorage |
|---|---|---|---|
| Sent on every request | YES | NO | NO |
| Capacity | ~4 KB / cookie | ~5–10 MB / origin | ~5–10 MB / tab |
| Hidden from JS | With HttpOnly |
NO | NO |
| Lifetime | Configurable (Expires / session) | Until cleared | Until tab closes |
| Cross-tab sharing | Yes (same origin) | Yes (same origin) | No — tab scoped |
| API | document.cookie + Set-Cookie header |
localStorage.setItem(k, v) |
sessionStorage.setItem(k, v) |
| Best for | Auth sessions, CSRF tokens, server-rendered prefs | Theme, draft text, app caches | Single-flow wizards, OAuth state |
Rule of thumb
If the server needs to read it on every request → cookie. If only the JS app needs it → localStorage (persistent) or sessionStorage (one-tab). Never store JWTs in localStorage unless your XSS posture is zero — and assume it isn't.
Cookies — Set-Cookie Anatomy & Every Attribute
A Set-Cookie header is one name=value pair followed by semicolon-separated attributes. Each attribute changes how the cookie is stored, sent, or restricted. To set multiple cookies, the server sends multiple Set-Cookie headers — never comma-separated.
Size limits
Per RFC 6265, browsers must support at least 4096 bytes per cookie and 50 cookies per domain. Modern browsers usually allow ~180 cookies per domain. The total Cookie header is capped at ~8 KB by most servers — blow that and you get HTTP 431.
📋 Every attribute, explained
The browser uses these to decide where a cookie is sent, how long it lives, and who can read it.
| Attribute | What it does | Default |
|---|---|---|
Domain |
Which host(s) receive the cookie. example.com sends to example.com + all subdomains. Omitting it locks the cookie to the exact origin host (no subdomains). |
exact host |
Path |
URL path prefix the cookie is sent on. Path=/api means it ships only on /api and below. Use / for app-wide cookies. |
current path |
Expires |
Absolute UTC date string. After this moment the browser discards the cookie. Omit both Expires and Max-Age and you get a session cookie (cleared when the browser closes). | session |
Max-Age |
Lifetime in seconds, relative to the server's response. Wins over Expires when both are present. Set Max-Age=0 to delete a cookie. |
— |
Secure |
Cookie is only sent over HTTPS. Without it, an attacker on the same Wi-Fi can sniff your session ID. Always set this in production. | off |
HttpOnly |
Hides the cookie from JavaScript (document.cookie can't see it). Mandatory for session cookies — prevents XSS from stealing the auth token. |
off |
SameSite |
Strict = never sent cross-site. Lax = sent on top-level GET navigations only (the modern default). None = sent everywhere, but requires Secure. |
Lax |
Priority |
Chrome-only hint (Low / Medium / High) for which cookies to evict first when the per-domain limit is hit. |
Medium |
Partitioned |
CHIPS — opt into per-top-site partitioned storage for third-party cookies. Replaces unrestricted cross-site tracking. | off |
__Host- prefix |
Magic name prefix that forces Secure, Path=/, and no Domain. Strongest origin-locked cookie. |
— |
SameSite=None without Secure → silently dropped
Since Chrome 80 (Feb 2020), any cookie marked SameSite=None must also be Secure or the browser refuses to store it. If you're embedding into a third-party iframe and your session breaks in production, this is almost always why.
Cookies in Flight — A Login Flow Walkthrough
The anatomy section showed what a cookie looks like. This one shows how one actually travels between browser and server during a real login → authenticated-request cycle.
🍪 The cookie flow, arrow by arrow
One card per arrow in the diagram above. Read them in order ① → ④ to follow the cookie's full life-cycle from the login POST to the next auto-authenticated request.
① POST /login {user, pass}
Plain HTTP POST. No session exists yet — just credentials in the body. The browser sends nothing special; the server is about to do the magic.
After receiving this, the server validates the password and creates a session record server-side (Redis / DB). The browser only gets a tiny opaque session ID back — never the real state.
② Set-Cookie: sid=abc123; HttpOnly; Secure
What makes this a cookie is the response header, not the body. The browser parses Set-Cookie and writes an entry into its cookie jar, indexed by Domain + Path + Name.
Each attribute changes how it's stored:
HttpOnly— hidden fromdocument.cookie, JS can't read it (defeats XSS exfiltration)Secure— only attached onhttps://SameSite=Lax— won't ship on cross-site POSTs (CSRF defense)Max-Age=28800— expires in 8 h; without it, dies on browser close
③ GET /dashboard · Cookie: sid=abc123
Frontend code does nothing. The browser auto-attaches the Cookie header on every subsequent request to the same site, as long as Domain / Path / Secure / SameSite all match.
This is the entire reason cookies exist. Compare with localStorage — there you'd have to manually attach a token to every fetch(), and any XSS payload would steal it.
④ 200 OK (session valid)
Server pulls the cookie, looks up the session record, recovers userId, and serves the response. Opaque ID → server-side record → user identity.
If the session expired or was revoked server-side (logout, password change, admin kick), the lookup returns nothing and the user gets 401 — even though they still have the cookie. The server is always the source of truth.
Three properties that make this work
HttpOnlydefeats XSS exfiltration. JS can cause the cookie to be sent (viafetch(..., { credentials: 'include' })) but cannot read its value. A malicious script on your page can't steal the session ID and replay it elsewhere.- The cookie value is opaque. No user data, no JWT, no email — just a random ID. Putting data in the cookie itself ships it on every request and makes tampering a real problem.
- The response writes the cookie, not the request. Frontend never calls
document.cookie = ...for auth. The server header is the source of truth.
The cross-subdomain trap
Frontend on app.example.com, API on api.example.com? You must do all three:
- Set the cookie's
Domain=.example.comso both subdomains see it. - Frontend uses
fetch(url, { credentials: 'include' }). - Server responds with
Access-Control-Allow-Credentials: trueand an explicitAccess-Control-Allow-Origin(not*).
Miss any one — browser silently drops the cookie. This is the classic "why isn't auth working in production" first-day bug.
Cookie Playground — Set, Inspect, Delete
This demo runs entirely on this page. Set a cookie below, watch the equivalent Set-Cookie header generated, see it appear in the cookie jar on the right, then inspect what document.cookie exposes. HttpOnly cookies cannot be created from JS by design — that flag only works when the server sets the cookie. The form lets you tick it so you can see the resulting header, but the browser won't actually store an HttpOnly cookie set this way.
Build a Set-Cookie
Equivalent header
This is what a server would send. The button on the left writes it client-side via document.cookie.
Cookie Jar (this document)
Raw document.cookie
Notice anything missing? Cookies set with HttpOnly by your real server won't appear here. That's the entire point of the flag — JS-borne XSS can't read them.
Try this
- Set
demo_pref=dark-modewith default flags. - Refresh this page — the cookie persists for an hour.
- Open DevTools → Application → Cookies and you'll see it there too.
- Set the same name with
Max-Age=0to delete it.
Web Storage Playgrounds — localStorage & sessionStorage
The Web Storage API gives you a tiny surface — setItem(k, v), getItem(k), removeItem(k), clear(), plus a storage event that fires in other tabs when you change something. Both demos below speak that exact API; only the lifetime differs.
Set values, refresh the page, open a new tab — and watch how each store behaves. Both demos prefix keys with demo_ so they don't collide with anything else this site stores.
💾 localStorage — survives refreshes, restarts, and reboots
Set / update an item
Equivalent JS
Open a new tab on this same site after setting a key, then click "Refresh view" in that tab — same data shows up. That's same-origin sharing.
Stored items 0 keys · 0 B
Raw JSON.stringify(localStorage)
Try this — persistence proof
- Set
theme=dark. - Hit
Cmd+R— still there. - Quit your browser entirely.
- Reopen and come back to this page. Still there.
That's the whole point of localStorage.
🪟 sessionStorage — dies when this tab closes
Set / update an item
Equivalent JS
Click the button below to open this same page in a brand-new tab. The new tab will show an empty jar — that's per-tab scoping.
Stored items 0 keys · 0 B
Raw JSON.stringify(sessionStorage)
Try this — per-tab scoping proof
- Click "Fill with checkout state" — a multi-step checkout object appears.
- Click "Open this page in new tab" — the jar in the new tab is empty.
- Refresh this tab — your data is still here.
- Close this tab and reopen the URL — gone.
That's the contract.
Real-World Use Cases — When To Pick Which
The comparison table tells you what; this section tells you when. Two strong examples per storage, with the code you'd actually ship — plus the anti-pattern that gets people fired.
The 10-second decision tree
Server needs it on every request? → Cookie
Only JS, must survive browser restart? → localStorage
Only JS, must die when tab closes? → sessionStorage
🍪 Cookies — when the server needs to know
🔐 Login session
The server creates a session row, hands the browser an opaque ID. Every subsequent request auto-attaches it; middleware looks up "who is this?" — no frontend code needed.
Why not localStorage? You'd have to attach a token manually to every fetch, and any XSS payload steals it instantly. HttpOnly is the only setup where JS can send credentials without being able to read them.
🧪 A/B test bucket at the edge
You want server-rendered HTML to come back with the right variant baked in — no flicker, no client-side hydration swap.
The cookie ships with every request, so the same user sees the same variant on a hard refresh, an SSR cache hit, or an offline-then-reconnected reload.
Cookie anti-pattern
Storing a 4 KB JWT with the user's full profile in a cookie. It ships on every request — images, fonts, API calls, analytics beacons. That's wasted bandwidth on every page load. Keep cookies tiny (an opaque session ID); put the profile in localStorage or fetch on demand.
💾 localStorage — only the browser cares, forever
🌗 Theme preference
User toggles dark mode at 11 pm. Closes laptop. Comes back Monday morning. Site should still be dark — without a server round-trip just to know that.
Persists across tabs, restarts, machine reboots. Server doesn't care, doesn't need to.
📝 Draft text auto-save
User is writing a long comment. Browser crashes. Without auto-save, work is gone. With four lines of code, it isn't.
5–10 MB ceiling means you can hold a lot of text. The storage event lets two open tabs sync as the user types — feature or bug, depending on UX.
localStorage anti-pattern
Storing a JWT or session token here. Any XSS = token exfiltrated, instantly, with no audit trail. The industry burned itself on this in 2017–2019; there's a reason every modern auth library now defaults to HttpOnly cookies for the access path.
🪟 sessionStorage — state that should die with the tab
🧭 Multi-step wizard / checkout
User is 4 steps into checkout. They open a product detail in a new tab. That new tab should not see step-4 state — it's a different shopping intent.
If you used localStorage, two open tabs would step on each other's checkout. If you used a cookie, you'd ship 2 KB of cart state with every API call including unrelated ones.
🔁 OAuth state & PKCE verifier
You redirect to Google. Google redirects back to /callback?state=xyz&code=.... You need to verify state matches what you sent — but only in this tab.
Per-tab scoping kills the attack where a victim opens two simultaneous OAuth flows and an attacker tricks the callback into validating against the wrong one.
sessionStorage anti-pattern
Putting "user is logged in" state in sessionStorage. Closing the tab should not log you out — that's surprising UX, and it's the wrong tool. For "remember me", use a cookie. For "log out when I close the browser", use a session cookie (no Max-Age) — not sessionStorage.
📋 Decide by question
| Ask yourself | Pick |
|---|---|
| Do I need this on the server? | Yes → cookie |
| Should it survive closing the tab? | Yes → localStorage · No → sessionStorage |
| Is it sensitive (auth token, PII)? | Cookie + HttpOnly. Never localStorage. |
| Do two tabs need to share it? | Yes → localStorage · No → sessionStorage |
| Is each new tab a fresh attempt? (wizard, OAuth, draft you don't want re-loaded) | sessionStorage |
| Will it be > 4 KB? | Not a cookie. localStorage or IndexedDB. |
| Will it be > 5 MB? | Not Web Storage. Use IndexedDB. |
Security Pitfalls — Across All Three Stores
The threat model differs per storage. Cookies face CSRF; localStorage faces XSS exfiltration; sessionStorage faces tab-takeover variants. Below: the four most common ways each one bites teams in production.
🪤 XSS → Cookie theft
An attacker injects <script>fetch('/x?c='+document.cookie)</script>. If your session cookie is missing HttpOnly, the attacker now owns the session. Mitigation: HttpOnly on every auth cookie + CSP + escape on output.
🎯 CSRF → forged requests
The browser auto-attaches cookies, so a malicious site can submit a form to your bank with the user's session attached. Mitigation: SameSite=Lax (now default) blocks cross-site POSTs, plus a CSRF token bound to the session.
🔓 MITM → cookie sniffing
Without Secure, the cookie ships over plain HTTP — any router on the path reads it. Mitigation: HSTS + Secure + __Host- prefix on session cookies.
♻️ Session fixation
Attacker tricks the user into authenticating with a session ID the attacker already knows. Mitigation: rotate the session ID on login (issue a new Set-Cookie the moment auth succeeds).
Production session cookie checklist
- ✓
HttpOnly - ✓
Secure - ✓
SameSite=Lax(orStrictfor high-value apps) - ✓
__Host-prefix - ✓ Short
Max-Agewith sliding expiry - ✓ Rotate on login & on privilege change
- ✓ Server-side revocation (sessions are real records, not just signed JWTs)
The End of Third-Party Cookies (and What's Replacing Them)
For two decades, ad networks tracked users across sites by relying on cookies set by an iframe to doubleclick.net being readable on every other site that embedded a DoubleClick pixel. Browsers have been progressively shutting this down.
Safari ITP (2017)
Apple's Intelligent Tracking Prevention started capping third-party cookie lifetimes — first to 30 days, then 24 hours, then full block by 2020.
Firefox ETP (2019)
Enhanced Tracking Protection blocks third-party cookies from a known-tracker list by default, then expanded to total cookie isolation per top-level site.
Chrome SameSite=Lax default (2020)
Cookies without an explicit SameSite attribute are treated as Lax. This alone broke a lot of cross-site flows that quietly relied on the old default.
CHIPS — Partitioned cookies (2024+)
The Partitioned attribute lets a third-party cookie still be set, but it's keyed by (top-level site, third-party origin) — so an embedded chat widget gets one bucket on siteA.com and a totally separate bucket on siteB.com. Tracking dies; legit embeds still work.
Privacy Sandbox / Topics API
Chrome's replacement for ad targeting: the browser categorizes the user locally into a few interest topics and exposes only those — no cross-site identifier.
Interview Q&A
Expires or Max-Age — the browser drops it when the window closes (modulo "restore tabs"). A persistent cookie has an explicit lifetime and survives restarts. Server-side "session" usually uses a persistent cookie holding a session ID, separate from the HTTP-cookie meaning of "session".document.cookie filters out HttpOnly entries. The cookie still rides on every fetch the browser makes, just not in the JS API. This is the single biggest XSS mitigation for session tokens.name, Domain, and Path with Max-Age=0 (or an Expires date in the past). The browser overwrites the existing entry and immediately discards it. Mismatched Domain or Path = a new cookie alongside the old one.__Host- prefix considered the strongest cookie config?Secure, must have Path=/, and must not have a Domain attribute. That guarantees the cookie is bound to one exact origin and can't be set by, or leak to, any subdomain. A subdomain takeover can no longer overwrite it.Domain=.google.com from evil.com?evil.com can set .evil.com but never .google.com. The Public Suffix List prevents .com or .co.uk from being set as a domain.Secure + the prod site is HTTPS but a redirect path drops it, (2) SameSite=None without Secure → silently dropped by Chrome, (3) Domain mismatch (set on www.x.com, request goes to x.com), (4) a CDN strips Set-Cookie on cacheable responses, (5) Path scoping too narrow.Cookie header: most servers cap at 8 KB total — exceed it and you get HTTP 431 Request Header Fields Too Large or a silent connection drop. Big tokens belong in Authorization (or in opaque server sessions), not in cookies.getItem/setItem blocks the main thread. For a few KB it's invisible, but a 4 MB JSON blob being parsed on every page load is a measurable jank source. If you're storing structured app state > 100 KB, switch to IndexedDB (async) — same persistence, no main-thread cost.sessionStorage. Each top-level browsing context gets its own. The exception: a tab opened via window.open (or a target=_blank link without noopener) inherits a copy of the opener's sessionStorage at creation time, then diverges. localStorage always shares across tabs of the same origin.storage event and when does it fire?localStorage changes. Useful for cross-tab sync — log a user out in one tab, all other open tabs notice. Caveat: it does not fire in the tab that made the change, and does not fire for sessionStorage. Try it: open this page in two tabs and change a demo_* key — the other tab will toast.localStorage is showing up in your performance budget. IndexedDB is async, transactional, and pretty much unlimited (within disk quota). Libraries like idb-keyval give you a localStorage-shaped API on top of IndexedDB if you don't need the full power.localStorage and sessionStorage are origin-scoped — scheme + host + port all must match. http://x.com and https://x.com get separate buckets. Cookies are domain-scoped by default and don't differentiate between schemes (a cookie set over HTTPS is sent over HTTP unless Secure is set). This mismatch is a recurring source of bugs.