Published Sep 16, 2025 ⦁ Updated Nov 4, 2026 ⦁ 6 min read

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.

5 MiB
localStorage per origin (plus 5 MiB sessionStorage)
10 MiB
maximum Web Storage data on all browsers
~4 KB
usual maximum size of one cookie
7 days
of browser use without interaction before Safari deletes script-written storage
60%
of total disk an origin can use for IndexedDB and Cache API in Chrome and Edge

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", and setItem('user', {name: 'Ana'}) stores the useless "[object Object]". MDN's setItem page shows exactly that example.
  • getItem returns null for missing keys, not undefined. Use ?? for defaults.
  • Prefer the methods over property access (localStorage.theme = 'dark'). Property access can collide with built-in names like length or key.
  • It's per origin. https://example.com, http://example.com and https://app.example.com each have separate storage, and other sites can't read yours. MDN notes that HTTP and HTTPS versions of a site get different localStorage objects.
  • Keys and values are UTF-16 strings, and integer keys are converted to strings automatically.

How to set localStorage in JavaScript, step by step

  1. Pick a key name with a prefix so you don't collide with libraries on the same origin: myapp:theme, not theme.
  2. Convert the value to a string. Strings go in as they are; numbers, booleans, objects and arrays go through JSON.stringify.
  3. Call setItem inside try...catch, because it throws when storage is full or blocked.
  4. Read it back with getItem and parse it, falling back to a default when the result is null.
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: localStorage doesn't exist in Node. In Next.js or other SSR code, only touch it in the browser (in useEffect, or behind typeof window !== 'undefined'), or you get ReferenceError: 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
Maximum storage per origin, as a share of total disk (best-effort mode)
Chrome / Edge 60%
Safari (browser apps) 60%
Safari (embedded web views) 15%
Firefox 10%

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, SameSite cookies, which JavaScript can't read. MDN says cookies that persist user sessions should have HttpOnly set, 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 innerHTML a 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.

Best for small settings, UI state and drafts under a few hundred KB: localStorage
Simplest API, persists across sessions, shared between tabs.
Best for state that should die with the tab: sessionStorage
Multi-step forms, one-tab wizards, per-tab scroll or filter state.
Best for login sessions: HttpOnly cookie
JavaScript can't read it, so an XSS bug can't steal it.
Best for large, structured or offline data: IndexedDB
Async, works in workers, stores objects and blobs, and gets far more quota.
Best for offline pages, assets and API responses: Cache API
Built for Request/Response pairs and service worker caching.

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):

  1. Only touch localStorage in 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.
  2. 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.
  3. 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.