What Browser Storage Is
Everything JavaScript holds in a variable disappears the moment the page reloads. localStorage is the simplest way to keep something across reloads: a small key-value store, provided by the browser, which survives closing the tab and even restarting the computer.
It is deliberately simple. Keys and values are strings, there are five methods, and everything is synchronous. That simplicity is exactly why it is the right tool for a theme preference, a draft a user has typed, a small shopping cart, or the state of a to-do list — and the wrong tool for anything large, sensitive or relational.
Two limits shape everything else in this lesson. Storage is scoped per origin, meaning the combination of protocol, host and port, so one site can never read another site's storage. And there is a quota — commonly a few megabytes, varying by browser — after which writing throws an error rather than silently discarding data.
One practical note before you start experimenting. Storage is tied to a real origin, so a sandboxed preview frame that does not have one — which includes some online playgrounds — throws a SecurityError on the very first call. Try the examples in this lesson on a page you have opened yourself, or straight in the console of any ordinary website.
// Save something
localStorage.setItem('theme', 'dark');
// Read it back - null when the key was never set
console.log(localStorage.getItem('theme')); // 'dark'
console.log(localStorage.getItem('missing')); // null
// Remove one key, or everything for this origin
localStorage.removeItem('theme');
// localStorage.clear();
// It survives a reload. Set this in a console, then refresh the page
// and read it again - it is still there.
localStorage.setItem('visits', '1'); - The Application tab of the browser developer tools shows everything currently in
localStoragefor the page you are on, and lets you edit or delete individual keys. That is by far the quickest way to debug storage code.
The Five Methods, and the String-Only Rule
The entire API is setItem(key, value), getItem(key), removeItem(key), clear() and key(index), plus a length property. There is nothing else to learn about the interface itself.
The rule that causes every early bug is that both keys and values must be strings — and anything else is converted with String() rather than rejected. Store the number 42 and you get back the string '42'. Store an object and you get back '[object Object]', which contains none of your data and cannot be recovered by any means. Nothing warns you at the point of saving.
So objects and arrays go in through JSON.stringify and come back through JSON.parse, exactly as the previous lesson described. Numbers and booleans need converting on the way out too: Number(localStorage.getItem('count')) for a number, and a comparison against 'true' for a boolean — because the string 'false' is a non-empty string and therefore truthy, which produces a bug that reads as though the condition is inverted.
getItem returns null for a key that was never set, and that is the case your code must handle on every read. Passing null to JSON.parse conveniently gives you null back rather than throwing — but parsing a value that has somehow become corrupted does throw, so the read still belongs inside a try/catch.
// Everything is converted to a string on the way in
localStorage.setItem('count', 42);
console.log(localStorage.getItem('count')); // '42' - a string
console.log(typeof localStorage.getItem('count')); // 'string'
console.log(Number(localStorage.getItem('count')) + 1); // 43
// Objects must go through JSON
const settings = { theme: 'dark', fontSize: 16 };
localStorage.setItem('bad', settings);
console.log(localStorage.getItem('bad')); // '[object Object]' - data lost
localStorage.setItem('settings', JSON.stringify(settings));
const back = JSON.parse(localStorage.getItem('settings'));
console.log(back.fontSize); // 16
// Booleans: the string 'false' is truthy
localStorage.setItem('seen', false);
console.log(localStorage.getItem('seen')); // 'false'
if (localStorage.getItem('seen')) {
console.log('this runs - almost certainly not what was intended');
}
console.log(localStorage.getItem('seen') === 'true'); // false - correct
// Listing everything stored for this origin
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
console.log(key, localStorage.getItem(key));
} localStorage.theme = 'dark'also works, because storage behaves like an object. Avoid it: a key namedlength,clearorkeywould collide with the API, andsetItemmakes the intent explicit.
localStorage, sessionStorage and Cookies
Three mechanisms store data in a browser, and they are not interchangeable. Choosing correctly between them is mostly a question of lifetime and of who else can read the data.
localStorage has no expiry at all. It stays until your code removes it or the user clears their browser data, and it is shared by every tab open on the same origin. Use it for preferences and for anything a user would reasonably expect to still be there tomorrow — a chosen theme, a saved draft, a language setting.
sessionStorage has an identical API and a different lifetime: it is cleared when the tab is closed, and each tab keeps its own separate copy. That per-tab isolation is exactly what you want for something belonging to one journey — a multi-step form, or which step of a checkout the user has reached — because two tabs then cannot interfere with each other.
Cookies are older, much smaller, and are sent to the server with every single request to that origin. That makes them the right tool for a session identifier and the wrong tool for almost anything else, since every kilobyte of cookie is a kilobyte added to every request the browser makes. A cookie marked HttpOnly cannot be read by JavaScript at all — and that, as the last section explains, is precisely why authentication tokens belong there rather than in localStorage.
// Same API, different lifetime
sessionStorage.setItem('step', '2');
console.log(sessionStorage.getItem('step')); // '2' - gone when the tab closes
localStorage.setItem('theme', 'dark'); // stays until removed
// Cookies from JavaScript are one long string
document.cookie = 'lang=en; max-age=86400; path=/';
console.log(document.cookie); // 'lang=en' plus every other readable cookie
// Choosing between them
// theme, language, saved draft -> localStorage
// current step of one checkout -> sessionStorage
// session token -> an HttpOnly cookie set by the server - Opening a link in a new tab gives that tab a fresh, empty
sessionStoragein most cases, while duplicating a tab may copy it. If your logic depends on either behaviour, test it — this is one of the less consistent corners of the platform.
Origins, Quota and the Errors You Will Hit
Storage is scoped to an origin: protocol, host and port together. http://example.com and https://example.com are different origins with entirely separate storage, which surprises people the first time a site moves to HTTPS and every saved preference appears to have vanished. Nothing was lost — it is simply in a different box.
The quota is finite, commonly a few megabytes per origin, and exceeding it throws a QuotaExceededError. That is a real error your code should handle rather than a theoretical one: any application that caches API responses in storage will reach it eventually, and an unhandled throw in the middle of a multi-key save leaves your data half written and inconsistent.
There are two further failure cases worth knowing. A user browsing privately may have storage that is cleared the moment the window closes. And some browsers, under some privacy settings, throw on the very first access rather than returning empty. Every serious application therefore wraps storage access in try/catch and degrades gracefully when it is unavailable, instead of breaking completely for the users who are most careful about their privacy.
One last consequence of the simple design: localStorage is synchronous, so a very large read or write blocks the thread — the same single thread that rendering and clicks depend on. Storing a few kilobytes is invisible. Storing several megabytes of JSON causes a visible freeze on every save. If you genuinely need that much data, IndexedDB is the asynchronous alternative built for it.
// Handle a full quota rather than letting it throw
function safeSet(key, value) {
try {
localStorage.setItem(key, value);
return true;
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.warn('Storage full - clearing cached data and retrying');
localStorage.removeItem('cache');
return false;
}
console.warn('Storage unavailable:', err.message);
return false; // private mode, blocked by settings, and so on
}
}
// Detect availability once, instead of guessing at every call
function storageAvailable() {
try {
const probe = '__probe__';
localStorage.setItem(probe, '1');
localStorage.removeItem(probe);
return true;
} catch {
return false;
}
}
console.log(storageAvailable());
if (!storageAvailable()) {
console.log('Fall back to in-memory state for this session');
} - Testing availability by writing and immediately removing a probe key is the standard technique, and it is necessary because merely checking that
window.localStorageexists does not tell you whether using it will throw.
A Small Storage Helper
Writing JSON.stringify and a try/catch at every call site is repetitive, and repetitive code only has to be wrong once. Two small functions fix it permanently.
save(key, value) stringifies and catches. load(key, fallback) reads, parses, and returns the fallback on any problem at all — key missing, JSON invalid, storage unavailable. Every call site then reads as though storage were a simple object, and there is exactly one place in your codebase where the failure behaviour is decided.
Two extras are worth building in from the very start. Prefix your keys with something that identifies your application, because on a shared origin — several student projects on one university domain, for instance — a bare key like 'settings' will eventually collide with somebody else's. And store a version number alongside the data, so that a future version of your code can recognise an old shape and discard it rather than crashing on it.
Storage has no expiry mechanism of its own, so if you are caching something that goes stale, store a timestamp with it and check the age when you read. That is about six extra lines, and it is the difference between a cache and a source of quietly outdated information.
const PREFIX = 'priodemy:';
const VERSION = 1;
function save(key, value) {
try {
const payload = { v: VERSION, at: Date.now(), data: value };
localStorage.setItem(PREFIX + key, JSON.stringify(payload));
return true;
} catch (err) {
console.warn('Could not save', key, err.message);
return false;
}
}
function load(key, fallback = null, maxAgeMs = null) {
try {
const raw = localStorage.getItem(PREFIX + key);
if (!raw) return fallback;
const payload = JSON.parse(raw);
if (payload.v !== VERSION) return fallback; // written by older code
if (maxAgeMs && Date.now() - payload.at > maxAgeMs) return fallback;
return payload.data;
} catch {
return fallback; // corrupted value, or storage unavailable
}
}
save('settings', { theme: 'dark', fontSize: 16 });
console.log(load('settings', { theme: 'light' })); // the saved object
console.log(load('nothing', 'default')); // 'default'
// The same helper as a five-minute cache
save('students', [{ name: 'Ananya' }]);
console.log(load('students', [], 5 * 60 * 1000)); - The version check is what lets you change the shape of your stored data without breaking existing users. Bump
VERSION, and everyone with old data silently gets the fallback instead of an exception thrown from a line that looks unrelated.
What Not to Store, and the Multi-Tab Problem
localStorage is readable by any JavaScript running on your origin. That includes every third-party script you have added — an analytics tag, an advertising script, a library loaded from a CDN — and any script an attacker manages to inject through exactly the kind of innerHTML mistake Lesson 16 warned about.
So the rule is direct and has no exceptions worth arguing about: no passwords, no authentication tokens, no personal data, no payment details. A session token sitting in localStorage is one cross-site-scripting bug away from being stolen, and unlike a cookie there is no HttpOnly flag available to protect it. Tokens belong in cookies that the server sets and JavaScript cannot read.
Storage is also not a database. There is no querying, no indexing, and no transaction — a multi-key update interrupted halfway through stays half done, and nothing will roll it back. If your data has relationships, needs searching, or must stay internally consistent across several keys, IndexedDB or a real server is the answer rather than a cleverer key naming scheme.
Finally, every tab on the same origin shares one localStorage. Two open tabs writing the same key will overwrite each other with no warning at all, which is a genuine source of "my settings keep reverting" reports. The storage event fires in the other tabs whenever a key changes, and it is how you keep them in step — a theme switched in one tab updating everywhere else is the classic use.
// Never store any of these
// localStorage.setItem('token', jwt); <- readable by any script
// localStorage.setItem('password', pw); <- never, under any circumstances
// localStorage.setItem('cardNumber', num); <- never
function applySettings(settings) {
document.body.dataset.theme = settings.theme;
}
// Keeping tabs in step. This fires in the OTHER tabs, never in the one
// that did the writing.
window.addEventListener('storage', (event) => {
if (event.key !== 'priodemy:settings') return;
if (!event.newValue) return; // the key was removed
console.log('changed in another tab');
console.log('old:', event.oldValue);
console.log('new:', event.newValue);
applySettings(JSON.parse(event.newValue).data);
}); - Keys and values are strings; anything else is converted with
String() - Objects go in with
JSON.stringifyand come back withJSON.parse getItemreturnsnullfor a key that was never set- The string
'false'is truthy — compare with=== 'true' sessionStoragefor one tab and one journey;localStorageto persist- Storage is per origin — HTTP and HTTPS do not share anything
- Wrap every access in
try/catch: quota, private mode, blocked settings - Never store tokens, passwords or personal data
- The
storageevent fires in other tabs, never in the one that wrote
- A quick way to check whether something belongs in
localStorage: would you mind if a user opened the developer tools, read it, edited it, and reloaded the page? If the answer is yes, it does not belong there.
