In this guide
Cookies vs localStorage vs sessionStorage: Where Should a Web App Keep Its Data?
Every web app needs somewhere to keep small pieces of information between page loads: who is logged in, what theme the user picked, the half-written comment they would rather not lose. Browsers offer three built-in options — cookies, localStorage, and sessionStorage — and they are not interchangeable. Picking the wrong one causes real bugs: login sessions that leak, preferences that vanish, or sensitive tokens exposed to any injected script. This guide explains what each storage does, how it behaves, and how to choose between them.
The problem these APIs solve
HTTP is stateless. Every request your browser sends is independent; the server has no memory of the previous one. So the client needs a way to carry state along: a session identifier so the server knows the request comes from the logged-in user, a theme preference, or a checkout draft.
Beginners usually pick storage by habit rather than by requirement. Common outcomes:
- A session token stored in
localStorage, readable by any third-party script on the page — one cross-site scripting (XSS) hole away from a stolen login. - A preference stored in a cookie, adding bytes to every HTTP request the browser makes, slowing page loads for no benefit.
- A form draft stored in
sessionStorage, lost when the user opens the same page in a second tab, becausesessionStorageis scoped per tab.
Understanding the three mechanisms takes about fifteen minutes and prevents all of these.
How the browser stores each one
Cookies
A cookie is a small name–value pair tied to a domain, usually created by the server through the Set-Cookie response header (JavaScript can also write them via document.cookie, with fewer privileges). On every subsequent request to that domain, the browser automatically attaches matching cookies in the Cookie header.
Key properties:
- Capacity: roughly 4 KB per cookie (name + value + attributes included), and browsers cap the number of cookies per domain — a few dozen is the practical limit, so cookies are for tokens and IDs, never bulk data.
- Accessibility: by default, JavaScript can read them, but a server can mark a cookie
HttpOnly, which hides it fromdocument.cookieentirely.Securemeans the cookie is only sent over HTTPS. - Lifetime: controlled by the
ExpiresorMax-Ageattribute. A cookie with neither is a session cookie — deleted when the browser closes.Max-Age=31536000keeps it for a year. - Traffic cost: cookies travel with every request — pages, images, API calls, everything. Keeping them small keeps requests small.
- Scope: the
DomainandPathattributes control which requests carry them; a cookie for.example.comgoes to all subdomains.
localStorage
localStorage is a per-origin key–value store exposed only to JavaScript. Data written with localStorage.setItem() persists across browser restarts until something explicitly deletes it — the user clearing site data, your code removing it, or a storage-quota wipe.
Key properties:
- Capacity: about 5 MB per origin in most browsers (some allow 10 MB; the spec only requires a minimum and leaves the rest to the browser).
- Accessibility: readable and writable by any JavaScript running on the same origin — including third-party scripts and anything injected through an XSS vulnerability. Never treat it as secure storage.
- Lifetime: no expiry mechanism at all. If you need expiry, you must implement it yourself (for example, storing a timestamp alongside the value and checking it on read).
- Scope: shared across all tabs and windows of the same origin. A change in one tab does not refresh data in another automatically — but the
storageevent lets other tabs react (see below). - API is synchronous: reading and writing blocks the main thread. Fine for a few kilobytes; a mistake for megabytes of data.
// A safe, typical use: a UI preference, nothing sensitive
localStorage.setItem("theme", "dark");
const theme = localStorage.getItem("theme");
sessionStorage
sessionStorage has the same API and roughly the same 5 MB quota as localStorage, but a different scope: data is isolated per tab. Each tab gets a fresh store, and it is discarded when the tab closes. Navigating within the tab keeps it; duplicating or closing the tab loses it (duplicating a tab copies the current data into the new tab's store, but the two then diverge).
This makes sessionStorage the natural home for state that belongs to one browser tab's session: multi-step form wizards, a "back to search results" position, or temporary tokens used during a checkout flow.
Side-by-side comparison
| Property | Cookies | localStorage | sessionStorage |
|---|---|---|---|
| Set by | Server (Set-Cookie) or JS |
JavaScript only | JavaScript only |
| Read by server | Yes — sent on every matching request | No — stays in the browser | No |
| Capacity | ~4 KB per cookie | ~5 MB per origin | ~5 MB per origin |
| Lifetime | Session (no expiry set) or until Max-Age/Expires |
Until explicitly cleared | Until the tab closes |
| Scope | Domain + path | Origin, all tabs | Origin, single tab |
| JS access | Optional — HttpOnly hides it |
Always readable by same-origin JS | Always readable by same-origin JS |
| Cross-tab visibility | Yes | Yes (via storage event) |
No — isolated per tab |
Security: the part that actually matters
Why auth tokens in localStorage are risky
Any JavaScript running on your page can read localStorage — your own code, your analytics library, that chat widget, and anything an attacker injects through an XSS vulnerability. If the session token lives in localStorage, one XSS hole hands the attacker a working login. HttpOnly cookies do not have this problem: injected scripts cannot read them, even when XSS happens.
This does not mean cookies are automatically safe. The threat model is different:
CSRF: the cookie-side attack
The browser sends cookies automatically, even on requests triggered from another site. A malicious page can submit a form to your banking app, and the browser attaches your login cookie. This is Cross-Site Request Forgery (CSRF).
The defences are layered, and all three are easy:
- SameSite attribute.
SameSite=Laxis the default in Chrome and most modern browsers: cookies are sent on top-level navigation but not on cross-site sub-requests (images, fetch, iframes).SameSite=Strictblocks them on cross-site navigations too, at the cost of slightly odd behaviour when users arrive from external links.SameSite=Noneallows cross-site sending but requiresSecure. - Anti-CSRF tokens for state-changing requests, checked server-side.
HttpOnly+Secureon the session cookie:HttpOnlykeeps JavaScript from reading it,Securekeeps it off plaintext HTTP.
A practical summary of the security posture
- Secrets and session identifiers belong in
HttpOnly; Secure; SameSitecookies set by the server, or in JavaScript memory (a variable) for the shortest-lived tokens.localStorageis the wrong home for anything whose theft would compromise the account. - Preferences and UI state (theme, language, sidebar collapsed) are perfect for
localStorage: not sensitive, and staying client-side keeps them out of every request. - Sensitive data never belongs in any of the three. Passwords, credit-card numbers, government IDs — store server-side, behind authentication.
Decision rules: where each kind of data goes
Use this checklist when you add a new piece of client-side state:
- Does the server need it on every request? → Cookie (session id, consent flag, locale the backend renders). Only the smallest values qualify, because cookies ride along on every request.
- Is it sensitive (session token, user ID for auth)? →
HttpOnlycookie from the server, neverlocalStorage. The one XSS-proof reading path is "JavaScript cannot read it". - Is it user-controlled UI state or cache? →
localStorage(theme, last-visited page, cached API response with a timestamp). - Is it an unfinished workflow in this tab? →
sessionStorage(form drafts, wizard steps, scroll position of a results list). - Is it bigger than ~5 MB, relational, or needed offline? → None of the above: use IndexedDB (see below).
- Can you re-derive it cheaply? → Do not store it at all. Fewer stored secrets is fewer liabilities.
Concrete examples:
- Login session:
HttpOnly; Secure; SameSite=Laxcookie set by the backend, containing a session id or a refresh token. The access token can live in memory during the page session. - Dark/light theme:
localStorage. Harmless, read before first paint, never sent to the server. - Half-written blog post draft:
localStorage, saved on each keystroke — but autosave to the server too for anything the user would be angry to lose. - Checkout wizard (address → payment → review):
sessionStorage, since the flow belongs to one tab and stale drafts in other tabs are confusing. - "Remember me": a long-lived
HttpOnlycookie with a rotating token, not a username+password inlocalStorage.
The storage event and quota errors
Keeping tabs in sync
When one tab writes to localStorage, other tabs on the same origin receive a storage event (the writing tab does not). This is the clean way to react to changes — for example, logging out of one tab logs the user out everywhere, or a theme change in one tab updates all open tabs:
window.addEventListener("storage", function (event) {
if (event.key === "theme") {
document.body.dataset.theme = event.newValue;
}
});
sessionStorage changes never fire this event across tabs, because other tabs have their own separate stores.
Handling full storage
Writing beyond the quota throws a QuotaExceededError (DOMException). It also fires in private-browsing modes where storage may be disabled or severely limited. Always wrap writes that matter in try/catch and degrade gracefully:
function savePreference(key, value) {
try {
localStorage.setItem(key, value);
} catch (error) {
// Storage full or unavailable (private mode): keep a memory fallback
console.warn("Could not persist preference:", error.name);
memoryFallback[key] = value;
}
}
Also note: JSON.stringify your objects before storing (everything is stored as strings), and JSON.parse with a try/catch when reading — users' stored data from an old app version may not match your current shape.
Clearing behaviour students often forget
- "Clear browsing data" wipes all three, plus IndexedDB.
- Safari's Intelligent Tracking Prevention can expire JavaScript-set cookies after 7 days and server-set cookies after 24 hours in some tracking contexts — "remember me for 30 days" is not guaranteed on every browser.
- Incognito/private mode gives each window an isolated store that is destroyed on close, and quota can be tiny — test your app there.
When to reach for IndexedDB instead
Cookies and the two Web Storage APIs are string key–value stores. When you need any of the following, use IndexedDB instead:
- Larger data: cached API responses, downloaded files, offline-first content — IndexedDB quotas are much larger (typically tens to hundreds of MB, with the browser prompting the user beyond that).
- Structured queries: IndexedDB stores objects in object stores with indexes, so you can query by fields instead of loading everything into memory.
- Non-blocking access: unlike
localStorage, IndexedDB is asynchronous — large reads do not freeze the page.
The trade-off is complexity: IndexedDB's API is verbose, which is why small wrapper libraries exist. Rule of thumb: string flags and tokens → the three storages above; anything that looks like a database → IndexedDB.
FAQ
Can the server read localStorage?
No. The server only sees what the browser sends — cookies (automatically) and whatever your JavaScript explicitly puts in request bodies or headers. If the backend needs a value from localStorage, your code must send it, for example in an Authorization header.
Is it safe to store a JWT in localStorage?
Only if you accept the XSS risk. Many tutorials do this, but the safer pattern is: short-lived access token in memory, long-lived refresh token in an HttpOnly; Secure; SameSite cookie. If you must store tokens client-side for a project, HttpOnly cookies are the better answer.
What does SameSite=Lax actually do?
It is Chrome's default since 2020. The cookie is sent on top-level GET navigations to your site (clicking a link), but not on cross-site sub-requests like images, fetch calls, or iframes. It blocks most CSRF while keeping normal link navigation working.
Why did my localStorage data disappear?
Usual causes: the user cleared site data, the browser ran out of quota and evicted it (mobile browsers are aggressive), the code ran in a private window whose store was discarded, or your app's origin changed (http vs https, or a different subdomain — each origin has its own store).
Cookies vs localStorage for a "remember me" feature?
Cookies — specifically a server-set HttpOnly; Secure cookie holding a random token, rotated on use, with an expiry. Storing credentials or long-lived tokens where JavaScript can read them is exactly the pattern XSS exploits.
Do cookies work with cross-origin APIs?
Only with cooperation: the API must send Access-Control-Allow-Credentials: true with a specific (not wildcard) origin, and your fetch must set credentials: "include". SameSite=None; Secure is needed for the browser to send the cookie cross-site at all.
Limitations
- These are browser-only mechanisms. They do not replace a database, they do not sync across devices, and anything stored on one phone will not appear on the user's laptop.
- localStorage has no expiry and no events in the writing tab. If you need TTL or reactivity, you build it yourself or move up to IndexedDB plus a state manager.
- All three are wiped by "clear site data" and are unreliable in private browsing. Anything the user must never lose (drafts, uploads) should sync to a server.
- Cookie behaviour varies by browser. Safari's tracking prevention aggressively expires cookies; Chrome's defaults (
SameSite=Lax) differ from older browsers. Test login and "remember me" flows in at least Chrome, Firefox, and Safari. - None of these encrypts data. "Not readable by JS" (
HttpOnly) is not encryption — the values are stored in plaintext on disk. Sensitive personal data belongs server-side.