JavaScript localStorage and sessionStorage: When to Use Each (and When Not To)
The Web Storage API gives you two synchronous, string-only, per-origin key/value stores: localStorage and sessionStorage. They are the simplest way to persist small amounts of data in the browser without a server round-trip.
This lesson covers what each one stores, the lifetime differences, the size and synchronicity gotchas, what NOT to put in them, and when you should reach for IndexedDB, cookies, or in-memory state instead. Plus the patterns that make them safer to use in real apps.
The two stores, side by side #
| Property | localStorage |
sessionStorage |
|---|---|---|
| Lifetime | Persistent — until the user clears site data | Per tab — cleared when the tab closes |
| Scope | Per origin (protocol + host + port) | Per origin and per tab |
| Capacity | ~5–10 MB depending on browser | Same |
| Sync/async | Synchronous | Synchronous |
| Storage format | String only | String only |
| Shared between tabs | Yes (with storage event) |
No — each tab has its own |
| Browser support | Universal | Universal |
Both share the same API. The only differences are lifetime and tab-scoping.
The API #
Four methods, two ways to call each.
// Set
localStorage.setItem('theme', 'dark');
localStorage.theme = 'dark'; // shorthand — same thing
// Get
localStorage.getItem('theme');
localStorage.theme;
// Remove
localStorage.removeItem('theme');
delete localStorage.theme;
// Clear everything for this origin
localStorage.clear();
// Iterate
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
const value = localStorage.getItem(key);
console.log(key, value);
}
The property-access shorthand looks nicer but the explicit method calls are clearer and avoid collisions with the API itself (e.g., localStorage.length is the API; setting localStorage.setItem('length', ...) is fine via the method but confusing as a property).
Strings only — JSON for everything else #
The storage only holds strings. Numbers, booleans, objects, arrays all need to be serialized.
// Wrong — value becomes the string '[object Object]'
localStorage.setItem('user', { name: 'Ada' });
// Right
localStorage.setItem('user', JSON.stringify({ name: 'Ada' }));
// Reading back
const user = JSON.parse(localStorage.getItem('user'));
Always wrap reads in a try/catch — JSON parsing throws on bad data:
function loadUser() {
try {
return JSON.parse(localStorage.getItem('user'));
} catch {
return null;
}
}
Real codebases usually wrap this in a thin helper:
const storage = {
get(key, fallback = null) {
const raw = localStorage.getItem(key);
if (raw === null) return fallback;
try { return JSON.parse(raw); } catch { return fallback; }
},
set(key, value) {
localStorage.setItem(key, JSON.stringify(value));
},
remove(key) {
localStorage.removeItem(key);
},
};
That's the API every team eventually writes.
When to use localStorage #
Good for:
- User preferences — theme, language, layout, dismissed dialogs
- Caching small server responses — anything where a slightly stale value is fine
- Offline-first scratch data — draft of a comment, notes someone hasn't submitted yet
- Feature flags from the server — cache them locally and refresh in the background
Bad for:
- Anything sensitive. JS-readable storage is XSS-vulnerable. Tokens here can be stolen.
- Anything large. 5 MB sounds like a lot until you start storing images or logs.
- Anything performance-critical. It's synchronous — every read/write blocks the main thread.
- Anything you'd cry about losing. Users clear site data, browsers wipe storage on quota pressure.
When to use sessionStorage #
Good for:
- Multi-step form state — survives page reloads within the tab but vanishes when the tab closes
- Scroll-restoration positions for a back-button workflow
- Tab-specific UI state — selected pane, opened panels
- Cross-page handoffs within a single user session ("continue where you left off after the redirect")
Don't use it for anything you want available across tabs or after the user closes the browser. That's localStorage.
The storage event: cross-tab sync #
localStorage writes in one tab fire a storage event in other tabs (not the one that wrote).
// Tab A
localStorage.setItem('theme', 'dark');
// Tab B
window.addEventListener('storage', (e) => {
console.log(`${e.key} changed from "${e.oldValue}" to "${e.newValue}"`);
});
Useful for keeping multi-tab UIs in sync — log out in one tab, all tabs log out.
The event does NOT fire in the tab that made the change. Fire your own custom event or update local state directly if you also need to react in-tab.
What NOT to store #
Authentication tokens (in many cases) #
XSS — any malicious script that runs on your origin can read everything in localStorage. If an attacker injects JavaScript (via a supply-chain attack, vulnerable dependency, etc.), they walk away with whatever auth token you stored.
Safer alternatives:
- HttpOnly, Secure, SameSite cookies — invisible to JS, set and read by the server.
- Short-lived in-memory tokens + a long-lived refresh token in an HttpOnly cookie.
This isn't a hard "never" rule — many apps use localStorage tokens with strong CSP and SRI to limit the blast radius — but it's the default trade-off you should understand before defaulting to localStorage for auth.
Large or binary data #
The 5 MB quota fills up fast. Plus, every read/write is synchronous and blocks the main thread. For caches > 1 MB, IndexedDB is the right answer:
- Asynchronous (doesn't block the main thread)
- Capacity in the hundreds of MB to GB range
- Indexed (fast queries on properties, not just key lookups)
- Stores structured data, including Blobs and File objects
Wrap IndexedDB in a library like idb-keyval (~600 bytes, KV-style API over IDB) or Dexie (richer queries) — the raw IndexedDB API is famously verbose.
Anything sensitive (PII, secrets) #
The user's machine is theirs. Anyone with physical access to it can read your storage. Don't store credit card details, social security numbers, or anything regulated.
A common pattern: persistent UI state with localStorage #
Most apps end up with code like this:
// On load
const theme = localStorage.getItem('theme') || 'auto';
setTheme(theme);
// On user change
themeToggle.addEventListener('click', () => {
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
});
// Cross-tab sync
window.addEventListener('storage', (e) => {
if (e.key === 'theme' && e.newValue) {
setTheme(e.newValue);
}
});
Three concerns: read at load, write on change, listen for cross-tab changes. The pattern repeats for every persisted preference.
Frameworks have their own helpers — React's useLocalStorage hook patterns, Angular signals with localStorage backing, Vue composables — but they all wrap this same three-part flow.
Quota errors #
When storage is full, writes throw:
try {
localStorage.setItem('huge', bigData);
} catch (e) {
if (e.name === 'QuotaExceededError') {
console.warn('Storage full');
// Clear oldest entries, fall back to memory-only, etc.
}
}
QuotaExceededError is the only routine error from the storage API. Wrap writes that handle data you don't control.
Disabled storage #
Some users disable cookies and storage entirely. Some privacy modes (Safari ITP) clear it after 7 days. Probe for availability before relying on it:
function storageAvailable() {
try {
const x = '__storage_test__';
localStorage.setItem(x, x);
localStorage.removeItem(x);
return true;
} catch {
return false;
}
}
If false, fall back to in-memory state — the user just loses persistence between reloads.
A decision tree #
| What you want to store | Best tool |
|---|---|
| Small JSON / preferences, persistent | localStorage |
| Small JSON, per-tab and ephemeral | sessionStorage |
| Anything > 1 MB, or queryable | IndexedDB |
| Auth tokens (defensive default) | HttpOnly, Secure cookie |
| Auth tokens (with strong CSP) | In-memory + refresh token in cookie |
| Anything sensitive | Server-side, not the browser |
| Real-time cross-tab state | BroadcastChannel (newer, cleaner than storage event) |
Know the right tool for the right size and lifetime, and 90% of "where do I save this?" decisions become obvious.
What's next #
That completes Module 7 and the full Modern JavaScript Tutorial path. You've now covered:
- Module 1: Foundations
- Module 2: Functions Deep Dive
- Module 3: Objects, Arrays & Iteration
- Module 4: Classes & OOP
- Module 5: Asynchronous JavaScript
- Module 6: Modern JS Internals
- Module 7: Browser & DOM
From here, the natural directions are framework-specific paths (Angular, React, Vue) or specialized topics like RxJS, TypeScript, testing, performance, and accessibility. Each builds on the foundation you just laid.
Try it yourself #
The synchronous-blocking-the-main-thread part of localStorage is the easiest to feel:
console.log('A');
localStorage.setItem('x', '1');
console.log('B');
setTimeout(() => console.log('C'), 0);
console.log('D');browser_sandboxOrder: A B D C.localStorage.setItem is synchronous — it blocks until the write commits. A, the setItem, B, and D all run on the same task, in source order. C runs in a separate task scheduled by setTimeout, so it lands last.This is fine for small values. For thousands of writes in a tight loop, that synchronous-on-the-main-thread behavior shows up as jank. IndexedDB doesn’t have this problem — it’s async.
That sync-blocking property is why localStorage is great for the occasional preference and bad for high-frequency caching. Match the tool to the access pattern.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


