localStorage and sessionStorage are the two Web Storage areas every browser ships with. localStorage keeps a small amount of data per origin until you clear it; sessionStorage keeps it only for the lifetime of one tab. Both are synchronous, string-only, and easy to misuse, so this page covers how to wrap them properly, when each one is the right choice, and how to survive the failure cases that crash a page silently.
What is localStorage and what belongs in it?
Think of localStorage as a small key-value shelf attached to your site. A value written under https://example.com is readable by every page on that origin and survives a browser restart. It is ideal for small preferences: a chosen theme, a saved shipping address, the last tab you were on, a draft you want to survive an accidental refresh.
It is not a database. The API is synchronous, so every read and write blocks the main thread for the moment it takes, and the total space for an origin is typically only a few megabytes. Storing a large list of records here will eventually fail, and when it fails the page has to cope with that.
Why does localStorage only accept strings?
Every value is converted to a string on the way in and handed back as a string on the way out. Numbers, booleans, objects and arrays all arrive as text, which quietly changes their type.
localStorage.setItem('theme', 'dark');
localStorage.setItem('rows', 25);
console.log(localStorage.getItem('theme')); // 'dark'
console.log(localStorage.getItem('rows')); // 25 ← a string, not a number
console.log(localStorage.getItem('missing')); // null ← absent keys give null
console.log(localStorage.length); // 2
localStorage.removeItem('theme');
localStorage.clear();
Storing an object directly is worse than it looks. The browser converts it with its own string conversion, so you get [object Object] back, with no warning that anything was lost.
How do you store objects without corrupting them?
Serialise with JSON.stringify on the way in and JSON.parse on the way out. Wrapping that in one small adapter keeps the rest of your code from having to remember the rule, and gives you a single place to handle parse failures.
// storage.js
export function createStore(backing, prefix = '') {
const fullKey = (name) => `${prefix}${name}`;
return {
get(name, fallback = null) {
const raw = backing.getItem(fullKey(name));
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch {
return fallback;
}
},
set(name, value) {
backing.setItem(fullKey(name), JSON.stringify(value));
},
remove(name) {
backing.removeItem(fullKey(name));
},
keys() {
const out = [];
for (let i = 0; i < backing.length; i += 1) {
const key = backing.key(i);
if (key !== null && key.startsWith(prefix)) out.push(key.slice(prefix.length));
}
return out;
},
};
}
The adapter takes the storage area as an argument on purpose. The same code then serves both areas, and a test can pass in a plain object instead of touching the browser at all. The keys() method walks the area by position rather than trusting an object listing, because a storage area is a special object whose properties are not ordinary own properties.
// app.js
import { createStore } from './storage.js';
const prefs = createStore(localStorage, 'prefs:');
const draft = createStore(sessionStorage, 'draft:');
prefs.set('theme', 'dark');
prefs.set('density', { rows: 25, compact: false });
console.log(prefs.get('theme')); // dark
console.log(prefs.get('density')); // { rows: 25, compact: false }
console.log(prefs.get('missing')); // null
console.log(prefs.keys().sort()); // [ 'density', 'theme' ] ← sorted so the output is stable
draft.set('reply', 'hello');
console.log(draft.get('reply')); // hello
| Method | Returns | Use it for |
|---|---|---|
setItem(key, value) | nothing | Writing; the value is stringified |
getItem(key) | string or null | Reading one key |
removeItem(key) | nothing | Deleting one key |
clear() | nothing | Wiping every key for the origin |
key(index) | string or null | Reading the key at a given position |
length | number | How many keys are stored |
When should you reach for sessionStorage instead?
The two areas share one API and differ in lifetime, scope and what happens on duplication.
| localStorage | sessionStorage | |
|---|---|---|
| Lifetime | Until you or the user clear it | Until that tab is closed |
| Sharing | Shared by every tab on the origin | Private to one tab |
| Duplicating a tab | The copy reads the same data | The copy starts with its own empty copy |
| Typical use | Theme, locale, remembered user, cart | Unsaved form text, wizard step, one-off state |
The duplication difference is worth internalising. If a user duplicates a tab with an unsaved draft in sessionStorage, the copy has nothing, which is usually what you want for a draft. If the draft lived in localStorage, both tabs would write to the same key and the second save would overwrite the first.
What happens when the quota runs out or storage is blocked?
Two failures matter. The first is a full quota: writing throws an error with the name QuotaExceededError. The second is storage being unavailable altogether, which some privacy modes and enterprise policies cause. If you let either one escape, the rest of your handler never runs and the user sees a dead button with no explanation.
// safe-set.js
export function safeSet(backing, key, value) {
try {
backing.setItem(key, JSON.stringify(value));
return { ok: true };
} catch (error) {
if (error.name === 'QuotaExceededError' || error.code === 22) {
return { ok: false, reason: 'storage-full' };
}
return { ok: false, reason: error.name };
}
}
const fullQuota = {
setItem() {
const error = new Error('out of space');
error.name = 'QuotaExceededError';
throw error;
},
};
console.log(safeSet(fullQuota, 'k', { a: 1 }));
// { ok: false, reason: 'storage-full' }
Returning a result object instead of letting the exception fly means the caller decides what to do: show a message, drop the oldest entry, or carry on without saving. Because the function receives the storage area as an argument, the failure path is testable without filling up a real browser — the stub in the snippet above throws the same error a full quota produces, and the function reports storage-full without crashing.
How do two open tabs of the same app stay in sync?
Changes in one tab do not fire events in the tab that made them. They do fire in every other tab on the origin, through the storage event. That event carries the key, the old value and the new value, which is everything you need to refresh a view.
// storage-events.js
export function describeStorageChange(event) {
if (event.key === null) {
return 'storage cleared in another tab';
}
if (event.newValue === null) {
return `removed: ${event.key}`;
}
return `changed: ${event.key} = ${event.newValue}`;
}
// app.js
import { describeStorageChange } from './storage-events.js';
window.addEventListener('storage', (event) => {
console.log(describeStorageChange(event));
});
There is one subtlety worth remembering: the event never fires in the tab that performed the write. If you rely on it to re-render, update your own view at the same time as you write, or route both paths through one function.
What to remember about Web Storage
- Both areas store strings only. Serialise objects with
JSON.stringifyand parse them back withJSON.parse. - Wrap the raw API in a small adapter so parse failures and prefixes are handled in one place.
- Use
localStoragefor preferences that should outlive the tab,sessionStoragefor drafts that should not. - Guard every write. A full quota throws
QuotaExceededError, and a blocked storage area throws on the first access. - The
storageevent syncs other tabs, never the tab that made the change.
Try it yourself: Open your browser's console on any page and run the first block. Then save an object with localStorage.setItem('user', { id: 7 }) and read it back — you will get the string [object Object], which is the mistake the adapter exists to prevent. Finally, put a page on localhost, open it in two tabs, change localStorage in one, and watch the storage event fire in the other.