What you'll learn
Quick Answer
Both store string key-value pairs per origin and are read synchronously. localStorage persists until code or the user clears it and is shared by every tab of the origin. sessionStorage is scoped to one tab and disappears when that tab closes. Neither can hold objects directly, so you must JSON.stringify on write and JSON.parse on read, wrap writes in try/catch for QuotaExceededError, and never keep session tokens there.
Everything you store becomes a string
Start with the bug that catches everyone exactly once:
localStorage.setItem('user', { name: 'Riya', city: 'Pune' });
console.log(localStorage.getItem('user')); // '[object Object]'Web Storage has one data type. Anything you pass to setItem is converted with the same rules as string concatenation, so an object becomes [object Object], an array becomes its comma-joined form, true becomes 'true', and 42 becomes '42'. There is no error and no warning. You find out later, when user.name is undefined.
The fix is to serialise explicitly:
localStorage.setItem('user', JSON.stringify({ name: 'Riya', city: 'Pune' }));
const user = JSON.parse(localStorage.getItem('user'));
console.log(user.city); // 'Pune'Two follow-on traps come with that. First, getItem returns null for a key that was never set, and JSON.parse(null) quietly returns null rather than throwing, because null is stringified to 'null' first. That is convenient. What is not convenient is a key holding corrupt data or the literal text undefined, where JSON.parse throws a SyntaxError that takes down your app on startup.
Second, JSON is lossy. Types that are not part of JSON do not survive the round trip:
const state = { savedAt: new Date(), tags: new Set(['dsa']), draft: undefined };
console.log(JSON.parse(JSON.stringify(state)));
// { savedAt: '2026-08-06T09:12:00.000Z', tags: {} }
// Date became a string, Set became {}, undefined key vanishedSo a value you stored as a Date comes back as a string and saved.getTime() throws. Store timestamps as numbers with Date.now() and rebuild the Date on read, and convert a Set or Map to an array before saving. Numbers are safe, but be aware that NaN and Infinity serialise to null.
The actual differences between the two
The two APIs are identical. setItem, getItem, removeItem, clear, key(i) and length behave the same way in both. Only the lifetime and the scope differ.
- localStorage persists with no expiry. Close the tab, close the browser, restart the machine, it is still there. Every tab and window on the same origin shares one store.
- sessionStorage is per tab. It survives a reload and usually a session restore, but closing the tab clears it. Two tabs on the same site have two independent sessionStorage stores, and duplicating a tab copies the contents into the new one.
Both are keyed by origin, meaning scheme plus host plus port. So http://site.com and https://site.com are separate stores, and so are site.com and app.site.com. This surprises people migrating a site to HTTPS: every saved preference appears to vanish, because the code is reading a different store.
Both are also synchronous. A read or write blocks the main thread, which for a few small keys is fine, but a startup path that parses a large JSON blob out of localStorage before first paint is a genuine cause of a slow-feeling page. Neither API is available inside a Web Worker, so if you need storage off the main thread, that is IndexedDB or the Cache API.
Choosing between them is usually straightforward. Theme choice, language preference, a dismissed banner, a saved draft: localStorage, because the user expects it to stick. A multi-step admission form, a wizard's current step, a one-off redirect target after login, a scroll position for this visit: sessionStorage, because leaking that into another tab produces confusing behaviour when someone opens two applications side by side.
One caution: in private or incognito windows the exact behaviour varies by browser, and some clear storage aggressively or restrict it further. Never treat storage as guaranteed. Always design so that a missing value falls back to a sane default rather than breaking the page.
Quota, and the error you will eventually hit
Web Storage is small by design. Most browsers allocate roughly five megabytes per origin, but the exact figure, whether it is shared with subdomains, and when the browser evicts it are all implementation details that differ between engines and change over time. Do not build anything that depends on a specific limit.
When you exceed it, setItem throws. It does not return false and it does not truncate. If that write happens during startup or inside a render, the whole page can break:
function writeJSON(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
// Name and code are not identical across engines, so check both
const quotaHit =
err instanceof DOMException &&
(err.name === 'QuotaExceededError' ||
err.name === 'NS_ERROR_DOM_QUOTA_REACHED' ||
err.code === 22 || err.code === 1014);
if (quotaHit) {
console.warn('Storage full, dropping cache');
return false;
}
throw err;
}
}Note what pushes you over the limit fastest. Storage is measured in UTF-16 code units, so most characters cost two bytes, and any Devanagari or emoji content costs the same or more. Base64 encoding inflates binary data by roughly a third, so caching images as data URLs in localStorage is a fast route to a full quota. Keeping a growing array of every action a user has taken is the other classic. If you need that much space, use IndexedDB, which is asynchronous, stores structured values without JSON, and gets a far larger allowance.
Reading is safe from quota errors, but not from everything. Some privacy settings and browser modes make even accessing localStorage throw a SecurityError, so a feature detection helper is worth having:
function storageAvailable() {
try {
const probe = '__probe__';
localStorage.setItem(probe, probe);
localStorage.removeItem(probe);
return true;
} catch {
return false;
}
}Also add your own expiry, because Web Storage has none. Store { value, expiresAt: Date.now() + 86400000 } and check the timestamp on read, deleting the key when it has passed. Otherwise a cached price list from months ago is still what your app shows.
Do not keep session tokens here
Every tutorial that stores a JWT in localStorage is teaching you a habit that fails a security review. The reason is precise, not vague.
Any JavaScript running on your origin can read all of it. There is no equivalent of a cookie's HttpOnly flag. That includes your own code, every npm package in your bundle, every analytics or chat widget script you pasted into the page, and any script an attacker manages to inject. One cross-site scripting hole, in your code or in a dependency, and the attacker runs localStorage.getItem('token') and exfiltrates a valid session in a single line. With an HttpOnly cookie the same XSS is still serious, but the raw token cannot be read out and posted to another server.
Storage also has no expiry, no automatic transmission and no scoping controls. A token left in localStorage stays valid on a shared lab machine long after the student walked away, because nothing cleared it.
The safer default for a browser session is an HttpOnly, Secure, SameSite cookie set by the server:
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400The browser sends it automatically, JavaScript cannot read it, and SameSite reduces cross-site request forgery risk. If your architecture truly requires a token in JavaScript, the usual pattern is a short-lived access token kept only in memory, refreshed through an HttpOnly refresh-token cookie, so nothing durable sits in storage.
The same reasoning applies to personal data. Do not put phone numbers, addresses, Aadhaar or PAN numbers, exam results or anything else you would not print on a notice board into Web Storage. It is unencrypted, readable in DevTools, and it stays on a machine the user may not own. Under India's data protection rules that is exactly the kind of careless retention you want to avoid. If you must cache something identifying, store a server-issued opaque ID and fetch the details when you need them.
How it compares with cookies, and what to use instead
The single biggest difference is what leaves your machine. Cookies are attached to every matching HTTP request automatically. Web Storage is never sent anywhere unless your code sends it. That makes cookies right for anything the server must know on each request, and wrong for anything the server does not need.
- Cookies: around 4 KB each, sent with every request to the matching domain and path, configurable expiry, and readable by the server. Can be hidden from JavaScript with
HttpOnly. Correct for session identifiers and auth. - localStorage: roughly 5 MB, never transmitted, no expiry, shared across tabs, readable by any script on the origin. Correct for preferences and non-sensitive caches.
- sessionStorage: same as localStorage but scoped to a tab and cleared when it closes. Correct for per-visit state.
- IndexedDB: asynchronous, much larger, stores structured values including Blobs without JSON, works in workers. Correct for offline data and large caches.
- Cache API: stores whole HTTP responses. Correct for service worker offline support.
A cookie carrying 3 KB of preferences adds that payload to every image, script and API request on the domain, which is pure waste. Move it to localStorage and the requests get smaller.
One genuinely useful feature of Web Storage is cross-tab synchronisation. The storage event fires in other tabs of the same origin when a key changes, which is how you make a theme toggle or a logout apply everywhere at once:
window.addEventListener('storage', (e) => {
if (e.key === 'theme') applyTheme(e.newValue);
if (e.key === 'session' && e.newValue === null) location.reload();
});The event does not fire in the tab that made the change, so update the local UI directly there. Also note that e.newValue is the raw string, so parse it the same way you would on read. For richer cross-tab messaging without touching storage at all, BroadcastChannel is the purpose-built API.
