JavaScript localStorage: How to Store Data in the Browser
localStorage is a built-in browser API that saves key-value pairs for your site, and they stay after the tab or browser is closed. You store data with localStorage.setItem('key', 'value'), read it with localStorage.getItem('key'), and remove it with removeItem or clear(). It only stores strings, so objects and arrays go through JSON.stringify and JSON.parse. You get about 5 MiB per origin (MDN). Use it for small, non-sensitive data such as preferences, drafts and UI state.
This guide covers the whole JavaScript localStorage API with code you can paste, then the parts most tutorials skip: validating what you read back, quotas per browser, private mode, file: URLs, the storage event across tabs, XSS risk, and how to use it in Next.js without hydration errors. Every limit below is checked against MDN as of November 2026.
What is localStorage? Browser local storage in one paragraph
Browser local storage (what most tutorials call JavaScript local storage) is part of the Web Storage API, supported in every current browser. window.localStorage gives your page a Storage object for its origin (protocol + host + port), and the data is saved across browser sessions (MDN). It isn't sent to your server, it isn't shared with other sites, and it isn't synced between devices. Think of the localStorage JS API as a small, persistent, string-only dictionary that lives in one browser profile.
The localStorage API in 5 methods
window.localStorage is a Storage object. The whole API:
// Save (overwrites any existing value for the key)
localStorage.setItem('theme', 'dark');
// Read: returns the string, or null if the key doesn't exist
const theme = localStorage.getItem('theme'); // "dark"
// Delete one key
localStorage.removeItem('theme');
// Delete everything this origin stored
localStorage.clear();
// Inspect keys
console.log(localStorage.length); // number of keys
console.log(localStorage.key(0)); // name of the first key (order isn't guaranteed)
Things to know from the start:
- Values are always strings.
setItem('count', 5)stores"5", andsetItem('user', {name: 'Ana'})stores the useless"[object Object]". MDN'ssetItempage shows exactly that example. getItemreturnsnullfor missing keys, notundefined. Use??for defaults.- Prefer the methods over property access (
localStorage.theme = 'dark'). Property access can collide with built-in names likelengthorkey. - It's per origin.
https://example.com,http://example.comandhttps://app.example.comeach have separate storage, and other sites can't read yours. MDN notes that HTTP and HTTPS versions of a site get differentlocalStorageobjects. - Keys and values are UTF-16 strings, and integer keys are converted to strings automatically.
How to set localStorage in JavaScript, step by step
- Pick a key name with a prefix so you don't collide with libraries on the same origin:
myapp:theme, nottheme. - Convert the value to a string. Strings go in as they are; numbers, booleans, objects and arrays go through
JSON.stringify. - Call
setIteminsidetry...catch, because it throws when storage is full or blocked. - Read it back with
getItemand parse it, falling back to a default when the result isnull.
try {
localStorage.setItem('myapp:fontSize', JSON.stringify(18));
} catch (err) {
console.warn('Could not save font size', err);
}
const fontSize = JSON.parse(localStorage.getItem('myapp:fontSize') ?? '16'); // 18
How to use local storage: a complete example
Here's a full, runnable page that remembers a dark-mode toggle and a draft note across reloads. Save it as an HTML file and serve it with any local server (not file:, see below).
<!doctype html>
<html>
<body>
<label><input type="checkbox" id="dark"> Dark mode</label>
<textarea id="note" rows="6" cols="40" placeholder="Type, then reload the page"></textarea>
<script>
const dark = document.getElementById('dark');
const note = document.getElementById('note');
// Restore
dark.checked = localStorage.getItem('demo:dark') === 'true';
note.value = localStorage.getItem('demo:note') ?? '';
document.body.style.background = dark.checked ? '#111' : '#fff';
// Save on change
dark.addEventListener('change', () => {
localStorage.setItem('demo:dark', String(dark.checked));
document.body.style.background = dark.checked ? '#111' : '#fff';
});
let t;
note.addEventListener('input', () => {
clearTimeout(t);
t = setTimeout(() => localStorage.setItem('demo:note', note.value), 300); // debounce writes
});
</script>
</body>
</html>
The 300 ms debounce matters: every local storage JavaScript call is synchronous, so writing on every keystroke of a long text blocks the main thread more than it needs to.
How to store objects and arrays in localStorage
Serialize with JSON on the way in, parse on the way out:
const settings = {theme: 'dark', fontSize: 16, tags: ['js', 'css']};
localStorage.setItem('settings', JSON.stringify(settings));
const saved = JSON.parse(localStorage.getItem('settings') ?? '{}');
console.log(saved.tags[0]); // "js"
Adding an item to a stored array means read, change, write back:
function addTodo(text) {
const todos = JSON.parse(localStorage.getItem('todos') ?? '[]');
todos.push({id: crypto.randomUUID(), text, done: false});
localStorage.setItem('todos', JSON.stringify(todos));
}
JSON drops what it can't represent: Date objects become strings, Map, Set, functions and undefined values are lost, and BigInt makes JSON.stringify throw. Store dates as ISO strings or timestamps and convert them back yourself. What you get back is always a fresh copy, so changing the parsed object doesn't change what's stored until you write it again.
| Value type | What JSON.stringify does |
How to store it |
|---|---|---|
String, number, boolean, null |
Kept | As is |
| Plain object, array | Kept (nested too) | As is |
Date |
Becomes an ISO string | Store date.toISOString(), rebuild with new Date(str) |
Map / Set |
Becomes {} |
Store [...map] / [...set], rebuild with new Map(arr) |
undefined, functions |
Dropped from objects | Don't store; recompute |
BigInt |
Throws TypeError |
Store value.toString() |
Blob, File, ArrayBuffer |
Not useful | Use IndexedDB instead |
If you want to look at what you actually saved, paste the stored string into the JSON formatter.
A safe wrapper you can copy
Real code has to deal with three failures: the key is missing, the stored JSON is corrupt (someone edited it in DevTools, or an old app version wrote a different format), and writes that throw. Writes throw when the quota is full (QuotaExceededError), and even reading localStorage can throw a SecurityError when the user has blocked site data or the page runs from a file: or data: URL (MDN). This small JS localStorage wrapper handles all three:
const storage = {
get(key, fallback = null) {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : JSON.parse(raw);
} catch {
return fallback; // blocked storage or corrupt JSON
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (err) {
if (err.name === 'QuotaExceededError') {
console.warn('localStorage is full; value not saved:', key);
}
return false;
}
},
remove(key) {
try { localStorage.removeItem(key); } catch {}
},
};
storage.set('draft', {title: 'Hello', body: '...'});
const draft = storage.get('draft', {title: '', body: ''});
A typed version for TypeScript
export function readJSON<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
return raw === null ? fallback : (JSON.parse(raw) as T);
} catch {
return fallback;
}
}
export function writeJSON(key: string, value: unknown): boolean {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch {
return false;
}
}
The as T cast is a promise to the compiler, not a check. The next section is about making that promise true.
Local storage validation: never trust what you read back
Anything in localStorage can be stale, edited by the user in DevTools, written by an older version of your app, or written by a different app on the same origin. Validate it like user input:
const SETTINGS_KEY = 'myapp:settings';
const SETTINGS_VERSION = 2;
const DEFAULTS = {version: SETTINGS_VERSION, theme: 'light', fontSize: 16};
function loadSettings() {
let data;
try {
data = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? 'null');
} catch {
return DEFAULTS; // corrupt JSON
}
if (!data || typeof data !== 'object') return DEFAULTS;
// Migrate old shapes instead of crashing on them
if (data.version === 1) data = {...data, fontSize: data.size ?? 16, version: 2};
if (data.version !== SETTINGS_VERSION) return DEFAULTS;
// Check every field you use
const theme = ['light', 'dark'].includes(data.theme) ? data.theme : DEFAULTS.theme;
const fontSize = Number.isInteger(data.fontSize) && data.fontSize >= 10 && data.fontSize <= 32
? data.fontSize : DEFAULTS.fontSize;
return {version: SETTINGS_VERSION, theme, fontSize};
}
For bigger shapes, a schema library such as Zod does the same with schema.safeParse(JSON.parse(raw)), returning a typed value or a failure you can replace with defaults. Two rules matter more than the tool: store a version number so you can migrate, and treat values as untrusted text if they ever end up in the DOM (more in the security section).
localStorage with expiration
localStorage never expires on its own. If you want a TTL, store the expiry next to the value:
function setWithExpiry(key, value, ttlMs) {
localStorage.setItem(key, JSON.stringify({value, expires: Date.now() + ttlMs}));
}
function getWithExpiry(key) {
const raw = localStorage.getItem(key);
if (!raw) return null;
const {value, expires} = JSON.parse(raw);
if (Date.now() > expires) {
localStorage.removeItem(key);
return null;
}
return value;
}
setWithExpiry('rates', {usd: 1, eur: 0.92}, 60 * 60 * 1000); // cache for 1 hour
Expired keys only disappear when you read them. If you cache many keys, sweep old ones on startup:
for (const key of Object.keys(localStorage)) {
if (!key.startsWith('cache:')) continue;
try {
const {expires} = JSON.parse(localStorage.getItem(key));
if (Date.now() > expires) localStorage.removeItem(key);
} catch {
localStorage.removeItem(key);
}
}
Sync between tabs with the storage event
When one tab changes localStorage, the browser fires a storage event in other tabs and windows of the same origin, but not in the tab that made the change. That's an easy way to sync a logout or a theme change across tabs:
window.addEventListener('storage', (event) => {
if (event.key === 'theme') {
document.documentElement.dataset.theme = event.newValue ?? 'light';
}
if (event.key === null) {
// localStorage.clear() was called in another tab
}
});
The StorageEvent tells you exactly what changed:
| Property | Meaning |
|---|---|
key |
The key that changed; null when clear() was called |
oldValue |
The previous value; null when the key was newly added |
newValue |
The new value; null when the key was removed or clear() was called |
url |
The URL of the document whose storage changed |
storageArea |
The Storage object affected (localStorage or sessionStorage) |
Remember the rule for the storage event: other tabs get it, the tab that wrote the value doesn't. For sessionStorage, the event only reaches same-origin iframes in the same tab, never other tabs. So update your own UI directly after setItem and use the listener only for the others.
For richer messaging between tabs, BroadcastChannel is the purpose-built API.
localStorage limits and gotchas
- Size: about 5 MiB each for localStorage and sessionStorage per origin, with a combined cap of 10 MiB, according to MDN. Keys and values are stored as UTF-16 strings, and exactly how browsers count them varies, so leave headroom.
- It's synchronous. Every call blocks the main thread. A few small reads are fine; parsing a multi-megabyte JSON blob on every keystroke causes jank. Google's web.dev storage guide recommends avoiding it for app data for this reason and using IndexedDB instead.
- Not available in Web Workers or Service Workers. Use IndexedDB there.
- Private and incognito windows: storage works, but it's wiped when the last private tab closes.
- Safari can delete it. With Intelligent Tracking Prevention, Safari deletes all script-writable storage (localStorage included) for a site after seven days of Safari use with no user interaction on that site. Web apps added to the Home Screen are handled differently (WebKit). Treat localStorage as a cache, not as the only copy of anything important.
- Server-side rendering:
localStoragedoesn't exist in Node. In Next.js or other SSR code, only touch it in the browser (inuseEffect, or behindtypeof window !== 'undefined'), or you getReferenceError: localStorage is not defined.
Local storage browser support and quotas
Every current browser supports localStorage with the same 5 MiB per origin. The bigger quotas below apply to IndexedDB, the Cache API and the Origin Private File System, which is where you go when 5 MiB isn't enough. From MDN's storage quotas page, as of November 2026:
| Browser | localStorage | Other storage per origin (best-effort) | Persistent storage |
|---|---|---|---|
| Chrome, Edge (Chromium) | ~5 MiB | Up to 60% of total disk | Same 60% |
| Firefox | ~5 MiB | Smaller of 10% of disk or 10 GiB per site group | Up to 50% of disk, capped at 8 TiB |
| Safari (macOS 14+, iOS 17+) | ~5 MiB | ~60% of disk in browser apps, ~15% in apps embedding web views | Same quota |
| Older Safari | ~5 MiB | 1 GiB, then Safari asks the user for more | Same |
Applies to IndexedDB, Cache API and OPFS. localStorage stays at about 5 MiB everywhere. Firefox also caps a site group at 10 GiB. Source: MDN, Storage quotas and eviction criteria
To see how much your origin is using, and to ask the browser not to evict it under storage pressure:
const {usage, quota} = await navigator.storage.estimate();
console.log(`Using ${(usage / 1e6).toFixed(1)} MB of ${(quota / 1e6).toFixed(0)} MB`);
const persisted = await navigator.storage.persist(); // true if granted
estimate() returns an estimate, not an exact count, and covers the whole origin rather than localStorage alone. Firefox shows the user a prompt for persist(); Safari and Chromium browsers decide automatically from how much the user interacts with the site. Under storage pressure, browsers evict the least recently used non-persistent origins first.
Measure how much localStorage you're using
There's no built-in "bytes used" for localStorage. A close estimate counts characters, since values are UTF-16 (2 bytes per code unit):
function localStorageBytes() {
let chars = 0;
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
chars += k.length + (localStorage.getItem(k)?.length ?? 0);
}
return chars * 2;
}
console.log((localStorageBytes() / 1024).toFixed(1), 'KiB');
Private browsing mode
In private windows (Incognito in Chrome, InPrivate in Edge, Private Browsing in Firefox and Safari), localStorage works normally while the window is open, and MDN says the data is cleared when the last private tab is closed. Quotas may also differ in private mode. Don't use a failed or empty read as a way to detect private mode; current browsers behave the same as normal windows until the session ends.
localStorage and file: URLs
If you open an HTML file straight from disk (file:///Users/you/index.html), localStorage file URL behavior is undefined by the spec. MDN says current browsers seem to give each file: URL its own storage area, but there's no guarantee, and some browsers have changed this over time. Accessing it can also throw a SecurityError because a file: origin isn't a valid scheme/host/port tuple. Serve local files over HTTP instead, for example with npx serve . or python3 -m http.server, and you get a normal http://localhost origin.
Is localStorage secure? Don't store tokens in it
Any JavaScript running on your page can read localStorage. That includes your third-party scripts and any script injected through an XSS bug. OWASP's HTML5 Security Cheat Sheet advises against storing session identifiers or sensitive data there. In practice:
- Don't store passwords, credit card data or personal data.
- For auth sessions, prefer
HttpOnly,Secure,SameSitecookies, which JavaScript can't read. MDN says cookies that persist user sessions should haveHttpOnlyset, and that this helps mitigate XSS (MDN). - If an SDK keeps tokens in localStorage (several auth SDKs do by default), keep them short-lived, set a strict Content Security Policy, and limit third-party scripts.
- Never
innerHTMLa value you read back from storage; a user can edit it in DevTools.
That last point is a real XSS path, sometimes called stored DOM XSS: if any part of your app writes untrusted text (a URL parameter, an API response) into localStorage and another part renders it as HTML, the payload runs on every visit until the key is cleared.
// Dangerous: runs any <img onerror=...> someone saved
list.innerHTML = localStorage.getItem('lastSearch');
// Safe: treated as text
list.textContent = localStorage.getItem('lastSearch') ?? '';
DevHunt lists security tools for scanning your front end for XSS and leaked secrets.
localStorage vs sessionStorage vs cookies vs IndexedDB vs Cache API
| localStorage | sessionStorage | Cookies | IndexedDB | Cache API | |
|---|---|---|---|---|---|
| Lifetime | Until deleted | Until the tab closes | Until Expires/Max-Age |
Until deleted | Until deleted |
| Shared between tabs | Yes (same origin) | No, per tab | Yes | Yes | Yes |
| Size | ~5 MiB | ~5 MiB | ~4 KB per cookie | Large, a share of disk | Large, a share of disk |
| Sent to server | No | No | Yes, with every request | No | No |
| API | Sync, strings only | Sync, strings only | document.cookie / Cookie Store API |
Async, structured data and blobs | Async, Request/Response pairs |
| Workers | No | No | Service workers, via Cookie Store API | Yes | Yes |
| HTTPS required | No | No | Only for Secure cookies |
No | Yes (secure contexts) |
| Good for | Preferences, drafts, small caches | One-tab wizard or form state | Sessions (HttpOnly) | Offline data, files, large datasets | Offline assets and API responses |
sessionStorage has the same API (sessionStorage.setItem and so on). It survives a page reload but not closing the tab, and each tab gets its own copy. For anything large, structured or offline-first, use IndexedDB, usually through a small wrapper library such as idb or Dexie.
The Cache API stores HTTP request and response pairs, is available from windows and workers (not only service workers), and only works in secure contexts. It's the right home for offline copies of pages, images and API responses. MDN's cookie guide is explicit that cookies are no longer the place for general client-side storage and points to Web Storage and IndexedDB instead, because cookies are sent with every request.
localStorage in React and Next.js (SSR gotchas)
Server rendering is where most localStorage bugs show up. Three rules for Next.js (App Router or Pages Router):
- Only touch
localStoragein client code. In the App Router that means a file with'use client'at the top, and even then only inside effects or event handlers, because client components are still rendered on the server once. - The first client render must match the server HTML. If you read localStorage during render, the server renders the default and the browser renders the stored value, and React reports a hydration mismatch.
- Subscribe if other tabs can change it, using the storage event.
useSyncExternalStore handles all three. Its getServerSnapshot argument is used during server rendering and hydration, and React requires it to return the same value on the server and on the first client render (React docs):
'use client';
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
}
export function useLocalStorage(key: string, fallback: string) {
const value = useSyncExternalStore(
subscribe,
() => localStorage.getItem(key) ?? fallback, // browser
() => fallback, // server + hydration
);
const set = (next: string) => {
localStorage.setItem(key, next);
window.dispatchEvent(new StorageEvent('storage', { key })); // notify this tab too
};
return [value, set] as const;
}
// Usage
// const [theme, setTheme] = useLocalStorage('theme', 'light');
The manual dispatchEvent is there because the browser doesn't fire storage in the tab that made the change.
For more on Next.js server code, see the Next.js API routes guide.
localStorage in Node.js
Node.js now ships a browser-compatible localStorage global. It was added in v22.4.0 behind a flag, the flag was dropped in v25.0.0, and since v26.0.0 accessing localStorage without the --localstorage-file option throws a DOMException (Node.js docs). Data goes unencrypted into the file you name, capped at 10 MB:
node --localstorage-file=./data.json app.js
Node's docs warn that in a server, this storage is shared across all users and requests, not per user. It's handy for CLI tools and tests that run local storage JS code written for the browser, not for sessions. In the browser-only code you share with a Node test runner, the in-memory class below is usually simpler.
A custom in-memory storage class (localStorage fallback and test double)
When localStorage is blocked, or in tests and SSR, a small class with the same CRUD methods lets the rest of your code stay unchanged:
class MemoryStorage {
#data = new Map();
get length() { return this.#data.size; }
key(index) { return [...this.#data.keys()][index] ?? null; }
getItem(key) { return this.#data.has(String(key)) ? this.#data.get(String(key)) : null; }
setItem(key, value) { this.#data.set(String(key), String(value)); }
removeItem(key) { this.#data.delete(String(key)); }
clear() { this.#data.clear(); }
has(key) { return this.#data.has(String(key)); } // extra: existence check
}
function getStorage() {
try {
const probe = '__probe__';
localStorage.setItem(probe, probe);
localStorage.removeItem(probe);
return localStorage;
} catch {
return new MemoryStorage(); // blocked, file:, or no window
}
}
export const store = getStorage();
Swap it in and the rest of your JS local storage code keeps working. It mirrors the real behavior that trips people up: values become strings, and missing keys return null. Data is gone on reload, which is the right failure for a fallback.
Inspect and edit localStorage in DevTools
In Chrome or Edge, open DevTools, go to Application > Storage > Local storage and select your origin to see, edit or delete keys. Firefox has the same under Storage, and Safari's Web Inspector under Storage. For more panels and shortcuts, see our Chrome DevTools guide and the Safari developer tools guide.
From the console, these three lines cover most debugging:
console.table(Object.entries(localStorage)); // every key and value
localStorage.getItem('settings'); // one key
localStorage.clear(); // reset this origin
In Chrome you can also clear everything for the site (localStorage, IndexedDB, cookies, caches) under Application > Storage > Clear site data, which is the fastest way to test a first visit.
Troubleshooting
These are the localStorage JavaScript errors and surprises you'll hit most often:
| Symptom | Cause | Fix |
|---|---|---|
ReferenceError: localStorage is not defined |
Code ran on the server (SSR, Node) | Move it into useEffect or an event handler, or check typeof window |
SecurityError when reading |
Storage blocked by the user, or a file:/data: URL |
Wrap in try, fall back to memory, serve over HTTP |
QuotaExceededError |
Over ~5 MiB for the origin | Delete old keys, compress, or move data to IndexedDB |
Value is "[object Object]" |
Object saved without JSON.stringify |
Stringify on write, parse on read |
Unexpected token in JSON.parse |
Stored value isn't JSON (plain string, edited, old format) | Parse in try, validate, fall back to defaults |
Data missing on www vs bare domain |
Different origins have separate storage | Redirect to one canonical origin |
| Storage event never fires | Listening in the same tab that wrote | Update your own tab directly; the event is for other tabs |
| Data gone after a week on iPhone | Safari's seven-day eviction of script-written storage | Keep the real copy on your server |
FAQ
Where is localStorage data stored?
On the user's device, in the browser profile's data folder, separately for each origin. It isn't sent to your server and isn't synced between browsers or devices.
Does localStorage persist after closing the browser?
Yes. It stays until your code removes it, the user clears site data, or the browser evicts it (for example Safari's seven-day rule or storage pressure). Private windows are the exception: their data is deleted when the session ends.
How do I check if a key exists in localStorage?
Use localStorage.getItem('key') !== null. Don't test with if (localStorage.getItem('key')), because a stored empty string is falsy.
How do I get all items from localStorage?
Object.entries(localStorage) returns all key-value pairs as strings. Or loop with for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); ... }.
Can I use localStorage in React?
Yes. In a client-rendered app, read it in a lazy useState initializer (useState(() => storage.get('theme', 'light'))) and write it back in a useEffect when the value changes. With server rendering (Next.js and similar), start from a default and read localStorage in useEffect after mount, or use useSyncExternalStore with a server snapshot, so the server and client HTML match.
What is localStorage in JavaScript?
It's a property of window that gives your page a persistent key-value store for its origin. You use it through five methods (setItem, getItem, removeItem, clear, key) plus the length property, and everything is stored as strings.
Does localStorage ever expire?
No. There's no built-in expiry. Data stays until you remove it, the user clears it, a private window closes, or the browser evicts it (Safari after seven days without interaction, or any browser under storage pressure). For a TTL, store a timestamp with the value and check it on read.
How much data can localStorage hold?
About 5 MiB per origin in every major browser, plus a separate 5 MiB for sessionStorage. When you go over, setItem throws QuotaExceededError. For more, use IndexedDB, which gets a share of the disk.
Is localStorage shared between tabs and subdomains?
It's shared between all tabs and windows of the same origin, and changes trigger a storage event in the other tabs. It isn't shared between origins, so app.example.com, example.com and the http version of either each have separate storage.
What's the difference between localStorage and cookies?
Cookies are small (usually about 4 KB each), are sent to the server with every request, and can be made unreadable to JavaScript with HttpOnly. localStorage holds about 5 MiB, never leaves the browser, and is always readable by scripts on the page. Use cookies for sessions and localStorage for client-only preferences.
Why doesn't localStorage work when I open an HTML file directly?
Pages opened from file: URLs have no proper origin, so the behavior is undefined: some browsers give each file its own storage, and access can throw a SecurityError. Run a local server (npx serve . or python3 -m http.server) and open http://localhost instead.
Can other websites read my localStorage?
No. Each origin has its own storage, and the same-origin policy keeps other sites out. The real risk is scripts that run on your own page, such as third-party tags or injected XSS code, which can read everything.