The quick answer: localStorage and sessionStorage are two flavours of the same browser key-value API (Web Storage). They hold strings, stay on the client and are readable by any JavaScript on your origin. localStorage keeps data until something deletes it; sessionStorage keeps it only for the lifetime of one tab. Cookies are small values the browser stores and sends to the server with every matching HTTP request. A cookie marked HttpOnly can’t be read by JavaScript at all.
That last point drives the security question interviewers almost always add: where should a login token live? The answer depends on what you’re defending against, and a good answer names XSS and CSRF explicitly.
Side-by-side comparison
| localStorage | sessionStorage | Cookies | |
|---|---|---|---|
| Lifetime | Until cleared by code, the user or the browser | Until the tab is closed (survives reloads) | Until Expires/Max-Age, or end of browser session if neither is set |
| Scope | Origin (scheme + host + port), shared by all tabs | Origin and a single tab | Domain and path you choose; can include subdomains |
| Sent to the server | Never | Never | Automatically, with every matching request |
| Readable by JavaScript | Yes | Yes | Yes, unless HttpOnly |
| Typical capacity | About 5 MiB per origin | About 5 MiB per origin | About 4 KB per cookie |
| API | Sync: setItem, getItem, removeItem, clear |
Same | document.cookie string, or the server’s Set-Cookie header |
| Available in workers | No | No | Not via document.cookie |
| Change notifications | storage event in other same-origin tabs |
No cross-tab sharing to notify | None built in (Chrome has the cookieStore API) |
Capacity figures vary by browser, and going over the Web Storage quota throws a QuotaExceededError from setItem. Don’t quote exact numbers as a guarantee.
Web Storage stores strings, and that bites
Web Storage is a browser API, but Node 22 ships an experimental implementation behind a flag, which makes the type coercion easy to show. This ran with node --experimental-webstorage --localstorage-file=./ls.db:
localStorage.setItem('count', 5);
localStorage.setItem('user', { name: 'Ada' });
localStorage.setItem('prefs', JSON.stringify({ theme: 'dark' }));
console.log(typeof localStorage.getItem('count'), localStorage.getItem('count'));
console.log(localStorage.getItem('user'));
console.log(JSON.parse(localStorage.getItem('prefs')).theme);
console.log(localStorage.getItem('missing'));
console.log(localStorage.getItem('count') + 1);string 5
[object Object]
dark
null
51Every value is converted to a string on the way in. The number came back as '5', so adding 1 produced '51'. The object was stringified to the useless '[object Object]', and that data is gone. Serialise with JSON.stringify and parse on read. A missing key returns null, not undefined, which matters if you write JSON.parse(getItem(k)) ?? fallback (parsing null returns null, so that pattern happens to work).
Two behaviours you can only see in a real browser:
- sessionStorage is per tab. Two tabs on the same site have separate sessionStorage, so a wizard’s draft in one tab doesn’t leak into the other. Reloading keeps it. Duplicating a tab copies the current values into the new tab, after which they diverge.
- The
storageevent. When one tab changes localStorage, other open tabs of the same origin receive astorageevent with the key, old value and new value. The tab that made the change doesn’t. That’s a cheap way to log every tab out when one tab logs out.
The API is synchronous and runs on the main thread, so storing large blobs in a hot path can cause jank. For big or structured data, IndexedDB is the better fit.
How cookies get to the server
Cookies are mostly set by the server with a Set-Cookie response header, and the browser sends matching ones back in a Cookie request header. Here’s a tiny Node 22 server that sets two cookies, with the follow-up request simulated the way a browser would send it:
res.setHeader('Set-Cookie', [
'session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600',
'theme=dark; Path=/; Max-Age=31536000',
]);[
'session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600',
'theme=dark; Path=/; Max-Age=31536000'
]
cookie header: session=abc123; theme=darkThe attributes stay with the browser. Only name=value pairs travel back, on every request to that path, including image and script requests. That’s the cost of cookies: put 3 KB of preferences in one and you pay 3 KB of upload on every request to the site. Store preferences the server doesn’t need in localStorage instead.
What each attribute does:
HttpOnly: hidden fromdocument.cookie. In the browser, readingdocument.cookieon this page would showtheme=darkand nosession.Secure: only sent over HTTPS (browsers treatlocalhostas secure for development).SameSite: controls whether the cookie goes on cross-site requests.Strictnever,Laxonly on top-level navigations such as following a link,Nonealways (and requiresSecure). Chrome treats a cookie with noSameSiteasLax; other browsers have differed, so set it explicitly.Max-Age/Expires: without either, it’s a session cookie that ends when the browser session does. Browsers that restore sessions may keep it longer.Domain/Path: widen or narrow which requests carry it. SettingDomain=example.comshares it with every subdomain, which is rarely what you want for a session.
Where to keep a login token
This is where interviews get interesting. Two common designs:
Token in localStorage, sent as an Authorization header. Simple, works across domains, and immune to CSRF because the browser never attaches it automatically. The problem is XSS. If an attacker gets any script running on your page (a compromised npm package, an unescaped comment field), that script can read localStorage.getItem('token') and send it to their server. They can then use your user’s session from anywhere until the token expires.
Session in an HttpOnly; Secure; SameSite cookie. Injected script can’t read the cookie, so it can’t be stolen and replayed elsewhere. But don’t claim HttpOnly “prevents XSS”. It doesn’t. While the malicious script runs, it can still call your API from the victim’s browser, and the browser will attach the cookie. HttpOnly limits the damage to the duration of the attack instead of handing over a reusable credential. Cookies also bring back CSRF: another site can make the browser send a request carrying the cookie. SameSite=Lax blocks most of that, and CSRF tokens cover what’s left (state-changing GET requests, or apps that need SameSite=None).
The usual recommendation for browser apps on one site is the cookie approach, plus a Content Security Policy and output escaping to make XSS less likely in the first place. Whatever you choose, never put secrets in any of the three that you wouldn’t accept a user seeing, since the user can read and edit all of them in DevTools.
Gotcha
Safari’s tracking prevention can delete script-written storage, localStorage included, for sites the user hasn’t interacted with during seven days of browser use. Treat Web Storage as a cache that might vanish, not a database.
Interview exercise: pick the storage
For each, choose localStorage, sessionStorage or a cookie and justify it: (a) a dark-mode preference, (b) a half-filled multi-step checkout form that shouldn’t appear in other tabs, (c) the user’s authenticated session for a server-rendered app, (d) a feature-flag payload of 40 KB.
Suggested answer
(a) localStorage: it should survive restarts and the server doesn’t need it. If the server renders the page and must avoid a flash of the wrong theme, a small cookie is a fair alternative, and saying why shows judgement. (b) sessionStorage: per-tab isolation is exactly what’s asked, and a reload won’t lose the progress. (c) An HttpOnly, Secure, SameSite=Lax cookie: the server needs it on every request including the first HTML load, and JavaScript never needs to read it. (d) localStorage (or IndexedDB): 40 KB is far past the roughly 4 KB cookie limit, and sending it on every request would be wasteful. Mention that it’s readable by any script on the page, so it must not contain anything sensitive.